{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-engine",
  "title": "Chat Engine",
  "description": "Agent Controller SSE client + transcript reducer.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "lib/agent-controller/events.ts",
      "content": "/**\n * Client-side model of the Mastra AgentController SSE stream — the TYPE SURFACE.\n *\n * The server's POST /agent-controller/stream emits `AgentControllerEvent`s as SSE. The AgentController\n * surface is richer than AI SDK UIMessage parts (sessions, modes, approvals,\n * subagents, tasks), so instead of forcing it through `useChat` we reduce the\n * events into a small transcript model the AI Elements can render directly.\n * See docs/coverage.md for the full event → element mapping.\n *\n * Only the subset of events the UI consumes is typed here; unknown events pass\n * through the reducer untouched.\n *\n * This module is types plus two pure message utilities, and imports nothing —\n * which is why every skin can depend on it without pulling in the reducer. The\n * fold itself lives in ./reduce, and its internal helpers in ./reduce-helpers.\n */\n\nexport type AgentControllerContentPart =\n  | { type: 'text'; text: string }\n  | { type: 'thinking'; thinking: string }\n  | { type: 'tool_call'; id: string; name: string; args: unknown }\n  | { type: 'tool_result'; id: string; name: string; result: unknown; isError?: boolean }\n  | { type: 'system_reminder'; message: string }\n  | { type: 'image'; data: string; mimeType: string }\n  | { type: 'file'; data: string; mediaType: string; filename?: string }\n  // forward-compat: any other content kind is carried but not specially rendered\n  | { type: string; [k: string]: unknown };\n\nexport type AgentControllerMessage = {\n  id: string;\n  role: 'user' | 'assistant' | 'system';\n  content: AgentControllerContentPart[];\n  createdAt?: string;\n  stopReason?: 'complete' | 'tool_use' | 'aborted' | 'error';\n  errorMessage?: string;\n};\n\nexport type AgentControllerTaskItem = {\n  id?: string;\n  content?: string;\n  title?: string;\n  status?: string;\n};\n\nexport type PendingApproval = { toolCallId: string; toolName: string; args: unknown };\n\n/** One selectable choice on an `ask_user` prompt (label is the answer value). */\nexport type SuspensionOption = { label: string; description?: string };\n\n/**\n * A parked tool suspension awaiting the user's answer — the agent-driven `ask_user`\n * flow. Folded from `tool_suspended`; its `suspendPayload` carries the question and,\n * optionally, choices. Free-text / single-select resume with a string; multi-select\n * resumes with the array of chosen labels. `null` when nothing is waiting.\n */\nexport type PendingSuspension = {\n  toolCallId: string;\n  toolName: string;\n  question: string;\n  options?: SuspensionOption[];\n  selectionMode?: 'single_select' | 'multi_select';\n};\n\n/** Token usage from the AgentController `usage_update` event (→ the Context element). */\nexport type AgentControllerUsage = {\n  promptTokens?: number;\n  completionTokens?: number;\n  totalTokens?: number;\n  reasoningTokens?: number;\n  cachedInputTokens?: number;\n};\n\n/**\n * Live shell state for the workbench Terminal tab. The controller streams the\n * sandbox's stdout/stderr as `shell_output` events (one per chunk, per running\n * command); we accumulate them into a single scrollback buffer. `running` drives\n * the Terminal's streaming caret and flips off when the run/tool settles.\n */\nexport type AgentControllerTerminal = { output: string; running: boolean };\n\n/**\n * Workspace lifecycle, folded from `workspace_ready` / `workspace_status_changed`\n * / `workspace_error`. Drives the workbench status dot so the panel reflects\n * whether the agent's filesystem/sandbox/browser is initializing, live, or failed.\n */\nexport type AgentControllerWorkspace = { status: string; name?: string; error?: string };\n\n/** One nested tool call a subagent made (subagent_tool_start → _tool_end). */\nexport type SubagentToolCall = {\n  name: string;\n  args?: unknown;\n  result?: unknown;\n  isError?: boolean;\n};\n\n/**\n * A single subagent invocation, keyed by the parent's `subagent` tool-call id.\n * Folded from the six `subagent_*` events into a live view the `Agent` element\n * renders inline where the parent's `subagent` tool call appears.\n */\nexport type SubagentRun = {\n  toolCallId: string;\n  agentType: string;\n  task?: string;\n  modelId?: string;\n  forked?: boolean;\n  /** Streamed assistant text from the subagent (subagent_text_delta). */\n  text: string;\n  /** Nested tool calls the subagent made. */\n  tools: SubagentToolCall[];\n  result?: unknown;\n  isError?: boolean;\n  durationMs?: number;\n  status: 'running' | 'done';\n};\n\n/**\n * Goal-run state, folded from `goal_evaluation` events (and seeded optimistically by a\n * `setGoal` POST). Drives the goal card: the objective, iteration progress against the\n * run budget, whether the judge has passed it, its status, and the latest judge reason.\n * `null` when no objective is set on the active thread.\n */\nexport type AgentControllerGoal = {\n  objective: string;\n  /** Evaluations consumed so far (runsUsed after the latest evaluation). */\n  iteration?: number;\n  /** Max evaluations before the goal stops. */\n  maxRuns?: number;\n  /** Whether the judge has ruled the objective complete. */\n  passed?: boolean;\n  status?: 'active' | 'paused' | 'done';\n  /** Judge feedback / stop reason. */\n  reason?: string;\n  /** Judge wants user input before continuing (loop paused, record still active). */\n  waitingForUser?: boolean;\n  /** The run budget (maxRuns) was reached without passing. */\n  maxRunsReached?: boolean;\n};\n\n/**\n * Observational-Memory state, folded from the `om_*` events. `om_status` (the primary,\n * fires per run) carries the token windows: how many message tokens have accumulated\n * toward the observation threshold, and how many observation tokens toward reflection —\n * plus the observe/reflect buffer status. The lifecycle events (observation/reflection\n * start/end, activation) accumulate into a bounded activity log. Drives the Memory panel.\n */\nexport type AgentControllerMemory = {\n  /** Latest `om_status` window snapshot — null before OM first reports in. */\n  status: {\n    messages: { tokens: number; threshold: number };\n    observations: { tokens: number; threshold: number };\n    observationBuffer: { status: string; chunks: number };\n    reflectionBuffer: { status: string };\n  } | null;\n  /** Rolling log of OM lifecycle activity (newest last), bounded. */\n  activity: { kind: 'observe' | 'reflect' | 'activate'; detail: string; failed?: boolean }[];\n  /** The most recently distilled observations text, when the Observer/Reflector surfaces one. */\n  observations: string | null;\n};\n\n/**\n * One recurring schedule the controller agent has set up (native `mastra.schedules`).\n * Fetched from `/api/agent-controller/schedules` (not folded from events) since schedule\n * CRUD happens through the agent's start_schedule / stop_schedule tools, then the\n * panel refetches when a run settles. Read-only in the UI (agent-driven).\n */\nexport type AgentControllerSchedule = {\n  id: string;\n  cron: string;\n  prompt: string;\n  status: 'active' | 'paused';\n  /** Epoch ms of the next planned fire (0 when paused/unknown). */\n  nextFireAt: number;\n  /** Epoch ms of the last fire, or null if it hasn't fired yet. */\n  lastFireAt?: number | null;\n  name?: string;\n};\n\n/**\n * A tool call whose input is still streaming — folded from the granular `tool_input_*`\n * / `tool_start` events that fire BEFORE the settled `message_update` tool-invocation\n * part arrives. Rendered as an input-streaming `<Tool>` for the window between the\n * first arg delta and the settled message part, then SUPPRESSED (removed) the moment a\n * message `tool_call` part with the same `toolCallId` lands — so it never double-renders\n * against the settled Tool.\n *\n * Real event order (captured live, 698.25): `tool_input_start` → `tool_input_delta`×N →\n * `tool_input_end` → `tool_start` → `message_update`[tool-invocation] → (gate). On a fast\n * model the settled part arrives near-instantly, so this state is mostly visible when the\n * model streams args slowly or the tool takes large inputs.\n */\nexport type ActiveTool = {\n  toolCallId: string;\n  name: string;\n  /** Accumulated raw args text (JSON), streamed in via `tool_input_delta.argsTextDelta`. */\n  argsText: string;\n  /** `input-streaming` while deltas arrive; `input-available` once `tool_input_end`/`tool_start` lands. */\n  state: 'input-streaming' | 'input-available';\n};\n\n/** What the SSE consumer folds events into and the view renders. */\nexport type AgentControllerTranscript = {\n  threadId: string | null;\n  messages: AgentControllerMessage[];\n  tasks: AgentControllerTaskItem[];\n  pendingApproval: PendingApproval | null;\n  /** A parked `ask_user` suspension awaiting the user's answer (→ the AskUserPrompt). */\n  pendingSuspension: PendingSuspension | null;\n  usage: AgentControllerUsage | null;\n  queuedFollowUps: number;\n  terminal: AgentControllerTerminal;\n  /** Latest workspace lifecycle snapshot (null before the workspace reports in). */\n  workspace: AgentControllerWorkspace | null;\n  /** Latest `info` status message from the run (a transient status line). */\n  info: string | null;\n  /** Subagent invocations, keyed by the parent `subagent` tool-call id (→ Agent element). */\n  subagents: SubagentRun[];\n  /** Active controller mode id, reflected from `mode_changed` (→ the mode switcher). */\n  activeMode: string | null;\n  /** Current goal-run state, folded from `goal_evaluation` (→ the goal card). */\n  goal: AgentControllerGoal | null;\n  /** Observational-Memory state, folded from `om_*` (→ the workbench Memory panel). */\n  memory: AgentControllerMemory | null;\n  /** Tools whose input is still streaming (input-streaming <Tool>), suppressed once settled. */\n  activeTools: ActiveTool[];\n  error: string | null;\n  done: boolean;\n};\n\nexport const emptyTranscript = (): AgentControllerTranscript => ({\n  threadId: null,\n  messages: [],\n  tasks: [],\n  pendingApproval: null,\n  pendingSuspension: null,\n  usage: null,\n  queuedFollowUps: 0,\n  terminal: { output: '', running: false },\n  workspace: null,\n  info: null,\n  subagents: [],\n  activeMode: null,\n  goal: null,\n  memory: null,\n  activeTools: [],\n  error: null,\n  done: false,\n});\n/**\n * Convert restored text-only UIMessages (AI SDK v7 parts shape, from `/agent-controller/threads/:id/messages`)\n * into transcript messages, so reopening a past conversation shows its history.\n * Only text is restored here; richer parts (tools, thinking) rehydrate on the live\n * stream when the conversation continues.\n */\nexport function uiMessagesToAgentController(\n  messages: Array<{ id: string; role: string; parts?: Array<{ type: string; text?: string }> }>,\n): AgentControllerMessage[] {\n  return messages.map((m) => ({\n    id: m.id,\n    role: m.role === 'assistant' ? 'assistant' : m.role === 'system' ? 'system' : 'user',\n    content: (m.parts ?? [])\n      .filter(\n        (p): p is { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string',\n      )\n      .map((p) => ({ type: 'text', text: p.text })),\n  }));\n}\n\n/** Collect tool_result content across all messages, keyed by tool-call id. */\nexport function collectToolResults(\n  messages: AgentControllerMessage[],\n): Map<string, AgentControllerContentPart> {\n  const byId = new Map<string, AgentControllerContentPart>();\n  for (const m of messages) {\n    for (const part of m.content) {\n      if (part.type === 'tool_result' && typeof (part as { id?: string }).id === 'string') {\n        byId.set((part as { id: string }).id, part);\n      }\n    }\n  }\n  return byId;\n}\n",
      "type": "registry:lib",
      "target": "lib/agent-controller/events.ts"
    },
    {
      "path": "lib/agent-controller/reduce.ts",
      "content": "/**\n * The AgentController fold: one event in, a new transcript out.\n *\n * Pure by design — no network, no React — which is what makes the whole\n * transport testable (tests/agent-controller/reduce.test.ts). Types live in\n * ./events; the normalisers and upserts used here live in ./reduce-helpers.\n */\n\nimport type {\n  AgentControllerGoal,\n  AgentControllerMessage,\n  AgentControllerTaskItem,\n  AgentControllerTranscript,\n  AgentControllerUsage,\n  SuspensionOption,\n} from './events';\nimport {\n  type AnyEvent,\n  foldMemoryActivity,\n  safeStringify,\n  settledToolCallIds,\n  upsertActiveTool,\n  upsertMessage,\n  upsertSubagent,\n} from './reduce-helpers';\n\n/**\n * Pure reducer: fold one AgentControllerEvent (or a transport sentinel) into the\n * transcript. Keeping this pure makes the whole transport testable without a\n * network or React.\n */\nexport function reduceAgentControllerEvent(\n  state: AgentControllerTranscript,\n  event: AnyEvent,\n): AgentControllerTranscript {\n  switch (event.type) {\n    case '__thread__':\n      return { ...state, threadId: event.threadId ?? state.threadId };\n    case '__done__':\n      return {\n        ...state,\n        done: true,\n        pendingApproval: null,\n        pendingSuspension: null,\n        // The run ended — any tool still marked \"streaming input\" is stale.\n        activeTools: [],\n        terminal: { ...state.terminal, running: false },\n      };\n    // The sandbox streams command stdout/stderr as it runs — accumulate it into\n    // the Terminal scrollback and mark a command in flight.\n    case 'shell_output':\n      return {\n        ...state,\n        terminal: { output: state.terminal.output + String(event.output ?? ''), running: true },\n      };\n    // Once the gate resolves (approved → tool runs, or declined → run ends), the\n    // approval is no longer pending. Clear it on any of these resolution events.\n    // These also settle a running command, so drop the Terminal's streaming caret.\n    // A `tool_end` also RESOLVES a matching `ask_user` suspension — the suspended\n    // tool re-ran with the answer and returned — so clear pendingSuspension only when\n    // this end is that tool (agent_end carries no toolCallId, so a live prompt stays).\n    case 'tool_end':\n    case 'agent_end':\n      return {\n        ...state,\n        pendingApproval: null,\n        pendingSuspension:\n          event.toolCallId && state.pendingSuspension?.toolCallId === event.toolCallId\n            ? null\n            : state.pendingSuspension,\n        // Drop the finished tool's live entry (agent_end has no id → clear all).\n        activeTools: event.toolCallId\n          ? state.activeTools.filter((t) => t.toolCallId !== event.toolCallId)\n          : [],\n        terminal: { ...state.terminal, running: false },\n      };\n    // A settled message part is the canonical Tool render. After folding it in, drop any\n    // live (input-streaming) entry whose tool_call now has a message part — so the live\n    // <Tool> is replaced by the settled one, never rendered alongside it (no double-render).\n    case 'message_start':\n    case 'message_update':\n    case 'message_end': {\n      if (!event.message) {\n        return state;\n      }\n      const messages = upsertMessage(state.messages, event.message as AgentControllerMessage);\n      const settled = settledToolCallIds(messages);\n      const activeTools = settled.size\n        ? state.activeTools.filter((t) => !settled.has(t.toolCallId))\n        : state.activeTools;\n      return { ...state, messages, activeTools };\n    }\n    // ── Live tool-input streaming (698.25) ──────────────────────────────────────\n    // The granular input events fold into `activeTools` so a tool renders in its\n    // input-streaming state while args stream, BEFORE the settled message part lands\n    // (which then suppresses the live entry — see the message case above).\n    case 'tool_input_start':\n      return {\n        ...state,\n        activeTools: upsertActiveTool(state.activeTools, {\n          toolCallId: String(event.toolCallId ?? ''),\n          name: String(event.toolName ?? ''),\n          argsText: '',\n          state: 'input-streaming',\n        }),\n      };\n    case 'tool_input_delta': {\n      const toolCallId = String(event.toolCallId ?? '');\n      const existing = state.activeTools.find((t) => t.toolCallId === toolCallId);\n      const delta =\n        typeof event.argsTextDelta === 'string'\n          ? event.argsTextDelta\n          : String(event.argsTextDelta ?? '');\n      return {\n        ...state,\n        activeTools: upsertActiveTool(state.activeTools, {\n          toolCallId,\n          name: existing?.name ?? String(event.toolName ?? ''),\n          argsText: (existing?.argsText ?? '') + delta,\n          state: 'input-streaming',\n        }),\n      };\n    }\n    case 'tool_input_end': {\n      const existing = state.activeTools.find((t) => t.toolCallId === event.toolCallId);\n      if (!existing) {\n        return state;\n      }\n      return {\n        ...state,\n        activeTools: upsertActiveTool(state.activeTools, { ...existing, state: 'input-available' }),\n      };\n    }\n    // The tool call is fully formed (args complete). Ensure a live entry exists with the\n    // final args — covers the case where input deltas were coalesced/missed.\n    case 'tool_start': {\n      const toolCallId = String(event.toolCallId ?? '');\n      const existing = state.activeTools.find((t) => t.toolCallId === toolCallId);\n      // If the settled message part already landed, don't resurrect a live entry.\n      if (settledToolCallIds(state.messages).has(toolCallId)) {\n        return state;\n      }\n      return {\n        ...state,\n        activeTools: upsertActiveTool(state.activeTools, {\n          toolCallId,\n          name: String(event.toolName ?? existing?.name ?? ''),\n          argsText: existing?.argsText || safeStringify(event.args),\n          state: 'input-available',\n        }),\n      };\n    }\n    case 'task_updated':\n      return { ...state, tasks: (event.tasks as AgentControllerTaskItem[]) ?? state.tasks };\n    case 'usage_update':\n      return { ...state, usage: (event.usage as AgentControllerUsage) ?? state.usage };\n    case 'follow_up_queued':\n      return { ...state, queuedFollowUps: Number(event.count ?? 0) };\n    // Workspace lifecycle → the workbench status dot. `workspace_ready` names the\n    // live workspace; `workspace_status_changed` reports a lifecycle transition\n    // (pending…destroyed); `workspace_error` surfaces a failure.\n    case 'workspace_ready':\n      return {\n        ...state,\n        workspace: {\n          status: 'ready',\n          ...(typeof event.workspaceName === 'string' ? { name: event.workspaceName } : {}),\n        },\n      };\n    case 'workspace_status_changed':\n      return {\n        ...state,\n        workspace: {\n          status: String(event.status ?? 'unknown'),\n          ...(event.error ? { error: String(event.error) } : {}),\n        },\n      };\n    case 'workspace_error':\n      return {\n        ...state,\n        workspace: {\n          status: 'error',\n          error: typeof event.error === 'string' ? event.error : JSON.stringify(event.error),\n        },\n      };\n    // Informational status line (transient — latest wins).\n    case 'info':\n      return {\n        ...state,\n        info: typeof event.message === 'string' ? event.message : String(event.message ?? ''),\n      };\n    // Active mode changed (manual switch or a plan→build transition). Reflect it so\n    // the mode switcher highlights the current mode mid-run.\n    case 'mode_changed':\n      return typeof event.modeId === 'string' ? { ...state, activeMode: event.modeId } : state;\n    // The native goal loop judged the objective after a turn. Fold the payload into the\n    // goal card — objective, iteration vs budget, pass/status, and the judge's reason —\n    // merging onto any goal seeded optimistically by setGoal.\n    case 'goal_evaluation': {\n      const p = (event.payload ?? {}) as {\n        objective?: string;\n        iteration?: number;\n        maxRuns?: number;\n        passed?: boolean;\n        status?: AgentControllerGoal['status'];\n        reason?: string;\n        waitingForUser?: boolean;\n        maxRunsReached?: boolean;\n      };\n      return {\n        ...state,\n        goal: {\n          objective:\n            typeof p.objective === 'string' && p.objective\n              ? p.objective\n              : (state.goal?.objective ?? ''),\n          iteration: p.iteration,\n          maxRuns: p.maxRuns ?? state.goal?.maxRuns,\n          passed: p.passed,\n          status: p.status,\n          reason: p.reason,\n          waitingForUser: p.waitingForUser,\n          maxRunsReached: p.maxRunsReached,\n        },\n      };\n    }\n    // Observational Memory (698.35) → the workbench Memory panel. `om_status` (per run)\n    // carries the token windows + buffer state; the lifecycle events accumulate into a\n    // bounded activity log. `observations` holds the latest distilled facts when surfaced.\n    case 'om_status': {\n      const w = (event.windows ?? {}) as {\n        active?: {\n          messages?: { tokens?: number; threshold?: number };\n          observations?: { tokens?: number; threshold?: number };\n        };\n        buffered?: {\n          observations?: { status?: string; chunks?: number };\n          reflection?: { status?: string };\n        };\n      };\n      return {\n        ...state,\n        memory: {\n          status: {\n            messages: {\n              tokens: Number(w.active?.messages?.tokens ?? 0),\n              threshold: Number(w.active?.messages?.threshold ?? 0),\n            },\n            observations: {\n              tokens: Number(w.active?.observations?.tokens ?? 0),\n              threshold: Number(w.active?.observations?.threshold ?? 0),\n            },\n            observationBuffer: {\n              status: String(w.buffered?.observations?.status ?? 'idle'),\n              chunks: Number(w.buffered?.observations?.chunks ?? 0),\n            },\n            reflectionBuffer: { status: String(w.buffered?.reflection?.status ?? 'idle') },\n          },\n          activity: state.memory?.activity ?? [],\n          observations: state.memory?.observations ?? null,\n        },\n      };\n    }\n    case 'om_observation_start':\n      return foldMemoryActivity(state, {\n        kind: 'observe',\n        detail: `Observing ${Number(event.tokensToObserve ?? 0)} tokens…`,\n      });\n    case 'om_observation_end':\n      return foldMemoryActivity(\n        state,\n        { kind: 'observe', detail: `Observed in ${Number(event.durationMs ?? 0)}ms` },\n        typeof event.observations === 'string' ? event.observations : undefined,\n      );\n    case 'om_observation_failed':\n      return foldMemoryActivity(state, {\n        kind: 'observe',\n        detail: `Observation failed: ${String(event.error ?? 'unknown')}`,\n        failed: true,\n      });\n    case 'om_reflection_start':\n      return foldMemoryActivity(state, {\n        kind: 'reflect',\n        detail: `Reflecting on ${Number(event.tokensToReflect ?? 0)} tokens…`,\n      });\n    case 'om_reflection_end':\n      return foldMemoryActivity(\n        state,\n        {\n          kind: 'reflect',\n          detail: `Compressed to ${Number(event.compressedTokens ?? 0)} tokens in ${Number(event.durationMs ?? 0)}ms`,\n        },\n        typeof event.observations === 'string' ? event.observations : undefined,\n      );\n    case 'om_reflection_failed':\n      return foldMemoryActivity(state, {\n        kind: 'reflect',\n        detail: `Reflection failed: ${String(event.error ?? 'unknown')}`,\n        failed: true,\n      });\n    case 'om_activation':\n      return foldMemoryActivity(state, {\n        kind: 'activate',\n        detail: `Activated ${Number(event.chunksActivated ?? 0)} chunk(s) (${Number(event.tokensActivated ?? 0)} tokens)`,\n      });\n    // Subagents (6 events, keyed by the parent `subagent` tool-call id) → the Agent\n    // element renders inline where the parent's `subagent` tool call appears.\n    case 'subagent_start':\n      return {\n        ...state,\n        subagents: upsertSubagent(state.subagents, event.toolCallId, {\n          agentType: String(event.agentType ?? 'subagent'),\n          task: typeof event.task === 'string' ? event.task : undefined,\n          modelId: typeof event.modelId === 'string' ? event.modelId : undefined,\n          forked: event.forked === true,\n          status: 'running',\n        }),\n      };\n    case 'subagent_text_delta':\n      return {\n        ...state,\n        subagents: upsertSubagent(state.subagents, event.toolCallId, (r) => ({\n          ...r,\n          text: r.text + String(event.textDelta ?? ''),\n        })),\n      };\n    case 'subagent_tool_start':\n      return {\n        ...state,\n        subagents: upsertSubagent(state.subagents, event.toolCallId, (r) => ({\n          ...r,\n          tools: [\n            ...r.tools,\n            { name: String(event.subToolName ?? 'tool'), args: event.subToolArgs },\n          ],\n        })),\n      };\n    case 'subagent_tool_end':\n      return {\n        ...state,\n        subagents: upsertSubagent(state.subagents, event.toolCallId, (r) => {\n          // Settle the last unresolved call with this name (falls back to the last one).\n          const name = String(event.subToolName ?? 'tool');\n          const tools = r.tools.slice();\n          let i = tools.map((t) => t.name).lastIndexOf(name);\n          if (i === -1) i = tools.length - 1;\n          if (i >= 0) {\n            tools[i] = {\n              ...tools[i],\n              result: event.subToolResult,\n              isError: event.isError === true,\n            };\n          }\n          return { ...r, tools };\n        }),\n      };\n    case 'subagent_end':\n      return {\n        ...state,\n        subagents: upsertSubagent(state.subagents, event.toolCallId, {\n          result: event.result,\n          isError: event.isError === true,\n          durationMs: typeof event.durationMs === 'number' ? event.durationMs : undefined,\n          status: 'done',\n        }),\n      };\n    case 'subagent_model_changed':\n      // The subagent's backing model changed — reflect it on running runs of that type.\n      return typeof event.agentType === 'string'\n        ? {\n            ...state,\n            subagents: state.subagents.map((r) =>\n              r.agentType === event.agentType && r.status === 'running'\n                ? { ...r, modelId: String(event.modelId ?? r.modelId) }\n                : r,\n            ),\n          }\n        : state;\n    case 'tool_approval_required':\n      return {\n        ...state,\n        pendingApproval: {\n          toolCallId: event.toolCallId,\n          toolName: event.toolName,\n          args: event.args,\n        },\n      };\n    // The agent called `ask_user` (or another suspending builtin) and the run parked\n    // awaiting an answer. Lift the question out of the suspend payload so the UI can\n    // render the prompt; the run resumes when the user answers (POST /agent-controller/answer).\n    case 'tool_suspended': {\n      const p = (event.suspendPayload ?? {}) as {\n        question?: string;\n        options?: SuspensionOption[];\n        selectionMode?: 'single_select' | 'multi_select';\n      };\n      // Only surface a prompt we can render — an ask_user-shaped payload with a\n      // question. Other suspending tools without one pass through untouched.\n      if (typeof p.question !== 'string' || !p.question) {\n        return state;\n      }\n      return {\n        ...state,\n        pendingSuspension: {\n          toolCallId: event.toolCallId,\n          toolName: event.toolName,\n          question: p.question,\n          ...(Array.isArray(p.options) ? { options: p.options } : {}),\n          ...(p.selectionMode ? { selectionMode: p.selectionMode } : {}),\n        },\n      };\n    }\n    // The suspension was cancelled server-side (e.g. the run failed before it could be\n    // resumed) — drop the matching prompt so the user isn't left answering a dead one.\n    case 'tool_suspension_cancelled':\n      return state.pendingSuspension?.toolCallId === event.toolCallId\n        ? { ...state, pendingSuspension: null }\n        : state;\n    case 'error':\n      return {\n        ...state,\n        activeTools: [],\n        error: typeof event.error === 'string' ? event.error : JSON.stringify(event.error),\n      };\n    default:\n      return state;\n  }\n}\n\n/** Reduce a batch of events (e.g. a full SSE flush) onto a starting state. */\nexport function reduceAgentControllerEvents(\n  state: AgentControllerTranscript,\n  events: AnyEvent[],\n): AgentControllerTranscript {\n  return events.reduce(reduceAgentControllerEvent, state);\n}\n",
      "type": "registry:lib",
      "target": "lib/agent-controller/reduce.ts"
    },
    {
      "path": "lib/agent-controller/reduce-helpers.ts",
      "content": "/**\n * Internals of the AgentController fold — see ./reduce.\n *\n * Split out of ./events so no single file in this engine runs past ~500 lines.\n * Nothing here is part of the engine's public surface: these are the normalisers\n * and upserts `reduceAgentControllerEvent` applies while folding one event.\n *\n * Only what ./reduce actually calls is exported. `mapFormat2Part`,\n * `normalizeContent` and `effectiveRole` stay module-private because they are\n * reached through `upsertMessage` rather than directly — exporting them would\n * widen the engine's surface for no caller.\n */\n\nimport type {\n  ActiveTool,\n  AgentControllerContentPart,\n  AgentControllerMemory,\n  AgentControllerMessage,\n  AgentControllerTranscript,\n  SubagentRun,\n} from './events';\n\n// biome-ignore lint/suspicious/noExplicitAny: AgentControllerEvent is a wide discriminated union; we switch on .type\nexport type AnyEvent = { type: string; [k: string]: any };\n\n/**\n * Map one Mastra \"format 2\" UI part (core ≥1.52) to one or more transcript parts.\n * Assistant text arrives as `{type:'text',text}`; the user's own turn arrives as\n * a `data-user-message` part with the text on `data.contents`; reasoning and tool\n * parts map to thinking / tool_call / tool_result.\n *\n * Tool parts come in TWO shapes and we handle both:\n *  - **v4-nested** `{type:'tool-invocation', toolInvocation:{state,toolCallId,toolName,args,result}}`\n *    — the shape Mastra core ≥1.52 actually emits. A `state:'result'` invocation\n *    expands to a `tool_call` + a paired `tool_result` (so the renderer, which\n *    pairs results to calls by id, shows args AND output).\n *  - **v5-flat** `tool-<name>` / `dynamic-tool` — kept for forward-compat.\n * Returns a single part, an array (call+result), or null.\n */\n// biome-ignore lint/suspicious/noExplicitAny: format-2 UI parts are a heterogeneous union\nfunction mapFormat2Part(p: any): AgentControllerContentPart | AgentControllerContentPart[] | null {\n  if (!p || typeof p !== 'object') return null;\n  const t = p.type as string | undefined;\n  if (t === 'text') {\n    return typeof p.text === 'string' && p.text ? { type: 'text', text: p.text } : null;\n  }\n  if (t === 'data-user-message' && typeof p.data?.contents === 'string') {\n    return { type: 'text', text: p.data.contents };\n  }\n  if (t === 'reasoning' && typeof p.text === 'string') {\n    return { type: 'thinking', thinking: p.text };\n  }\n  // v4-nested tool part — check BEFORE the flat `tool-` branch (its type also\n  // starts with `tool-`, but its data lives under `toolInvocation`, not on `p`).\n  if (t === 'tool-invocation' && p.toolInvocation && typeof p.toolInvocation === 'object') {\n    const ti = p.toolInvocation;\n    const id = ti.toolCallId ?? '';\n    const name = ti.toolName ?? 'tool';\n    const call: AgentControllerContentPart = { type: 'tool_call', id, name, args: ti.args };\n    if (ti.state === 'result') {\n      return [call, { type: 'tool_result', id, name, result: ti.result, isError: !!ti.isError }];\n    }\n    return call;\n  }\n  if (typeof t === 'string' && (t.startsWith('tool-') || t === 'dynamic-tool')) {\n    const name = t === 'dynamic-tool' ? (p.toolName ?? 'tool') : t.replace('tool-', '');\n    if (p.output !== undefined || p.state === 'output-available' || p.state === 'output-error') {\n      return {\n        type: 'tool_result',\n        id: p.toolCallId ?? '',\n        name,\n        result: p.output,\n        isError: p.state === 'output-error',\n      };\n    }\n    return { type: 'tool_call', id: p.toolCallId ?? '', name, args: p.input };\n  }\n  return null;\n}\n\n/**\n * Coerce a message's `content` into `AgentControllerContentPart[]`. Handles: a plain\n * array (older format), a bare string (some providers), and — crucially — the\n * Mastra core ≥1.52 \"format 2\" object `{ format, parts, metadata }` whose `parts`\n * are AI-SDK UI parts. Everything else (null shells before parts stream) → [].\n */\nfunction normalizeContent(content: unknown): AgentControllerContentPart[] {\n  if (Array.isArray(content)) return content as AgentControllerContentPart[];\n  if (typeof content === 'string' && content.length > 0) return [{ type: 'text', text: content }];\n  const parts = (content as { parts?: unknown } | null)?.parts;\n  if (Array.isArray(parts)) {\n    // flatMap: a v4-nested tool part expands to a tool_call + tool_result pair.\n    return parts.flatMap((p) => {\n      const mapped = mapFormat2Part(p);\n      if (mapped === null) return [];\n      return Array.isArray(mapped) ? mapped : [mapped];\n    });\n  }\n  return [];\n}\n\n/**\n * A `role: \"signal\"` message that carries the user's turn (a `data-user-message`\n * part, or `metadata.signal.type === \"user\"`) IS the user speaking — surface it\n * as `user` so the renderer shows it (it only renders user/assistant). Any other\n * signal becomes `system` (rendered nowhere).\n */\nfunction effectiveRole(msg: AgentControllerMessage): 'user' | 'assistant' | 'system' {\n  const role = msg.role as string;\n  if (role === 'user' || role === 'assistant' || role === 'system') return role;\n  const c = msg.content as { parts?: unknown[]; metadata?: { signal?: { type?: string } } } | null;\n  const isUser =\n    c?.metadata?.signal?.type === 'user' ||\n    (Array.isArray(c?.parts) &&\n      c.parts.some((p) => (p as { type?: string })?.type === 'data-user-message'));\n  return isUser ? 'user' : 'system';\n}\n\n/** Upsert an active (still-streaming) tool by toolCallId. */\nexport function upsertActiveTool(list: ActiveTool[], t: ActiveTool): ActiveTool[] {\n  const idx = list.findIndex((x) => x.toolCallId === t.toolCallId);\n  if (idx === -1) {\n    return [...list, t];\n  }\n  const next = list.slice();\n  next[idx] = t;\n  return next;\n}\n\n/** All toolCallIds that have a SETTLED `tool_call` part somewhere in the messages. */\nexport function settledToolCallIds(messages: AgentControllerMessage[]): Set<string> {\n  const ids = new Set<string>();\n  for (const m of messages) {\n    for (const p of m.content) {\n      if (p.type === 'tool_call') {\n        const id = (p as { id?: string }).id;\n        if (typeof id === 'string' && id) {\n          ids.add(id);\n        }\n      }\n    }\n  }\n  return ids;\n}\n\n/** Best-effort JSON string of tool args (for the input-available fallback). */\nexport function safeStringify(v: unknown): string {\n  if (typeof v === 'string') {\n    return v;\n  }\n  try {\n    return JSON.stringify(v ?? {});\n  } catch {\n    return '';\n  }\n}\n\nexport function upsertMessage(\n  messages: AgentControllerMessage[],\n  msg: AgentControllerMessage,\n): AgentControllerMessage[] {\n  const m: AgentControllerMessage = {\n    ...msg,\n    role: effectiveRole(msg),\n    content: Array.isArray(msg.content) ? msg.content : normalizeContent(msg.content),\n  };\n  const idx = messages.findIndex((x) => x.id === m.id);\n  if (idx === -1) {\n    return [...messages, m];\n  }\n  const next = messages.slice();\n  next[idx] = m;\n  return next;\n}\n\n/**\n * Upsert a subagent run by `toolCallId`, applying `patch` (a partial merge, or a\n * function of the current run). Creates a stub `running` run if none exists yet,\n * so out-of-order events (e.g. a delta before start) never drop.\n */\nexport function upsertSubagent(\n  runs: SubagentRun[],\n  toolCallId: string,\n  patch: Partial<SubagentRun> | ((r: SubagentRun) => SubagentRun),\n): SubagentRun[] {\n  const idx = runs.findIndex((r) => r.toolCallId === toolCallId);\n  const base: SubagentRun =\n    idx === -1\n      ? { toolCallId, agentType: 'subagent', text: '', tools: [], status: 'running' }\n      : runs[idx];\n  const nextRun = typeof patch === 'function' ? patch(base) : { ...base, ...patch };\n  const next = runs.slice();\n  if (idx === -1) next.push(nextRun);\n  else next[idx] = nextRun;\n  return next;\n}\n\n/**\n * Append one OM lifecycle entry to the memory activity log (bounded to the last 30,\n * newest last) and optionally update the distilled observations text. Seeds an empty\n * memory shell if none exists yet (an activity event can arrive before the first\n * `om_status`), so nothing drops.\n */\nexport function foldMemoryActivity(\n  state: AgentControllerTranscript,\n  entry: AgentControllerMemory['activity'][number],\n  observations?: string,\n): AgentControllerTranscript {\n  const base: AgentControllerMemory = state.memory ?? {\n    status: null,\n    activity: [],\n    observations: null,\n  };\n  return {\n    ...state,\n    memory: {\n      ...base,\n      activity: [...base.activity, entry].slice(-30),\n      observations: observations ?? base.observations,\n    },\n  };\n}\n",
      "type": "registry:lib",
      "target": "lib/agent-controller/reduce-helpers.ts"
    },
    {
      "path": "lib/agent-controller/use-agent-controller-chat.ts",
      "content": "'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n  type AgentControllerGoal,\n  type AgentControllerSchedule,\n  type AgentControllerTranscript,\n  emptyTranscript,\n  uiMessagesToAgentController,\n} from './events';\nimport { reduceAgentControllerEvent } from './reduce';\n\nexport type AgentControllerStatus = 'ready' | 'streaming' | 'error';\n\n/**\n * The Agent Controller transport, mirroring `useChat`'s shape (`{ messages,\n * sendMessage, status }`) but speaking the AgentController SSE protocol instead of the\n * AI SDK UIMessage stream. POSTs `{ text, threadId }` to the proxy, parses the\n * `data:`-framed SSE, and folds each AgentControllerEvent into a transcript.\n */\nexport function useAgentControllerChat(endpoint = '/api/agent-controller/stream') {\n  const [transcript, setTranscript] = useState<AgentControllerTranscript>(emptyTranscript);\n  const [status, setStatus] = useState<AgentControllerStatus>('ready');\n  // Recurring schedules the agent has set up (fetched, not folded from events —\n  // schedule CRUD goes through the start/stop_schedule tools, so the panel refetches\n  // when a run settles). Read-only in the UI.\n  const [schedules, setSchedules] = useState<AgentControllerSchedule[]>([]);\n  const threadRef = useRef<string | null>(null);\n  // Bumps whenever a turn completes so the conversation sidebar refetches (a new\n  // thread appears / an existing one re-sorts to the top).\n  const [refreshSignal, setRefreshSignal] = useState(0);\n\n  // Load any objective already set on the session's active thread (durable across a\n  // reload within the server's lifetime). Live updates then arrive as `goal_evaluation`\n  // over the SSE and fold onto this via the reducer.\n  useEffect(() => {\n    let cancelled = false;\n    (async () => {\n      try {\n        const res = await fetch('/api/agent-controller/goal', { cache: 'no-store' });\n        const data = (await res.json()) as {\n          objective?: {\n            objective?: string;\n            maxRuns?: number;\n            runsUsed?: number;\n            status?: AgentControllerGoal['status'];\n          } | null;\n        };\n        if (cancelled || !data.objective?.objective) return;\n        const o = data.objective;\n        setTranscript((s) => ({\n          ...s,\n          goal: s.goal ?? {\n            objective: o.objective as string,\n            maxRuns: o.maxRuns,\n            iteration: o.runsUsed,\n            status: o.status,\n          },\n        }));\n      } catch {\n        // No goal surfaced if the server is unreachable — the card just stays hidden.\n      }\n    })();\n    return () => {\n      cancelled = true;\n    };\n  }, []);\n\n  // Hydrate the Memory panel with the facts OM has already distilled (resource-scoped,\n  // so they apply across all of this user's chats). Called on mount and after switching/\n  // resetting threads, so the panel shows learned memory on load instead of a bare empty\n  // state — the live token windows still fill in from `om_status` on the next run.\n  const refreshMemory = useCallback(async () => {\n    try {\n      const res = await fetch('/api/agent-controller/om', { cache: 'no-store' });\n      const data = (await res.json()) as { observations?: string | null };\n      if (!data.observations) return;\n      setTranscript((s) => ({\n        ...s,\n        memory: {\n          status: s.memory?.status ?? null,\n          activity: s.memory?.activity ?? [],\n          observations: s.memory?.observations ?? data.observations ?? null,\n        },\n      }));\n    } catch {\n      // The panel just stays in its empty state if the server is unreachable.\n    }\n  }, []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only hydration\n  useEffect(() => {\n    refreshMemory();\n  }, []);\n\n  // Load the agent's recurring schedules (the Schedules panel). Called on mount and\n  // after each run settles, so a schedule the agent just created/paused shows up.\n  const refreshSchedules = useCallback(async () => {\n    try {\n      const res = await fetch('/api/agent-controller/schedules', { cache: 'no-store' });\n      const data = (await res.json()) as { schedules?: AgentControllerSchedule[] };\n      setSchedules(Array.isArray(data.schedules) ? data.schedules : []);\n    } catch {\n      // The panel just shows its empty state if the server is unreachable.\n    }\n  }, []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only hydration\n  useEffect(() => {\n    refreshSchedules();\n  }, []);\n\n  const sendMessage = useCallback(\n    async (\n      text: string,\n      opts?: {\n        model?: string;\n        webSearch?: boolean;\n        files?: Array<{ url: string; mediaType: string; filename?: string }>;\n      },\n    ) => {\n      if (!text.trim() && !opts?.files?.length) {\n        return;\n      }\n      // NOTE: no optimistic user message — the AgentController echoes the user turn as its\n      // own `message_start`/`message_end` (role=user) at the start of the run. Adding\n      // our own would render the user's message twice (different ids → both kept).\n      setTranscript((s) => ({ ...s, error: null, done: false }));\n      setStatus('streaming');\n\n      try {\n        const res = await fetch(endpoint, {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          // The composer's model / web-search / attachment selections ride along so\n          // the controller honors them: the run switches model via `session.model.switch`,\n          // web search flows through the request context, and files pass to sendMessage.\n          body: JSON.stringify({\n            text,\n            threadId: threadRef.current,\n            model: opts?.model,\n            webSearch: opts?.webSearch,\n            files: opts?.files,\n          }),\n        });\n        if (!res.ok || !res.body) {\n          throw new Error(`controller stream failed: ${res.status}`);\n        }\n\n        const reader = res.body.getReader();\n        const decoder = new TextDecoder();\n        let buffer = '';\n        let chunk = await reader.read();\n        while (!chunk.done) {\n          buffer += decoder.decode(chunk.value, { stream: true });\n          const frames = buffer.split('\\n\\n');\n          buffer = frames.pop() ?? '';\n          for (const frame of frames) {\n            const dataLine = frame.split('\\n').find((l) => l.startsWith('data:'));\n            if (!dataLine) {\n              continue;\n            }\n            const json = dataLine.slice(5).trim();\n            if (!json) {\n              continue;\n            }\n            let event: { type: string; [k: string]: unknown };\n            try {\n              event = JSON.parse(json);\n            } catch {\n              continue;\n            }\n            if (event.type === '__thread__' && typeof event.threadId === 'string') {\n              threadRef.current = event.threadId;\n            }\n            setTranscript((s) => reduceAgentControllerEvent(s, event));\n          }\n          chunk = await reader.read();\n        }\n        setStatus('ready');\n        // A completed turn may have created a new thread (or bumped an existing\n        // one) — nudge the sidebar to refetch.\n        setRefreshSignal((n) => n + 1);\n        // The turn may also have created or paused a schedule — refresh the panel.\n        refreshSchedules();\n      } catch (err) {\n        setTranscript((s) => ({\n          ...s,\n          error: err instanceof Error ? err.message : String(err),\n        }));\n        setStatus('error');\n      }\n    },\n    [endpoint, refreshSchedules],\n  );\n\n  /**\n   * Resolve a parked tool-approval gate. The continuation events arrive on the\n   * still-open SSE from the original sendMessage, so this just fires the decision.\n   */\n  const approve = useCallback(async (decision: 'approve' | 'decline' | 'always_allow_category') => {\n    await fetch('/api/agent-controller/approve', {\n      method: 'POST',\n      headers: { 'content-type': 'application/json' },\n      body: JSON.stringify({ decision }),\n    });\n  }, []);\n\n  /**\n   * Answer a parked `ask_user` suspension. Optimistically clear the prompt so it\n   * closes at once; the continuation events arrive on the still-open SSE from the\n   * original sendMessage (same pattern as `approve`). `answer` is a string (free-text\n   * / single choice) or a string[] of chosen labels (multi-select).\n   */\n  const answerQuestion = useCallback(async (answer: string | string[], toolCallId?: string) => {\n    setTranscript((s) => ({ ...s, pendingSuspension: null }));\n    await fetch('/api/agent-controller/answer', {\n      method: 'POST',\n      headers: { 'content-type': 'application/json' },\n      body: JSON.stringify({ answer, ...(toolCallId ? { toolCallId } : {}) }),\n    });\n  }, []);\n\n  /** Clear the workbench Terminal scrollback (the shell buffer is cumulative). */\n  const clearTerminal = useCallback(() => {\n    setTranscript((s) => ({ ...s, terminal: { ...s.terminal, output: '' } }));\n  }, []);\n\n  /**\n   * Load a past conversation into the view. Fetches its messages (text-only\n   * restore) and seeds a fresh transcript; follow-up turns continue this thread\n   * (threadRef) so the server switches to it and memory carries context.\n   */\n  const openThread = useCallback(\n    async (threadId: string) => {\n      try {\n        const res = await fetch(\n          `/api/agent-controller/threads/${encodeURIComponent(threadId)}/messages`,\n          {\n            cache: 'no-store',\n          },\n        );\n        const data = (await res.json()) as {\n          messages?: Array<{\n            id: string;\n            role: string;\n            parts?: Array<{ type: string; text?: string }>;\n          }>;\n        };\n        threadRef.current = threadId;\n        setStatus('ready');\n        setTranscript({\n          ...emptyTranscript(),\n          threadId,\n          messages: uiMessagesToAgentController(data.messages ?? []),\n        });\n      } catch {\n        threadRef.current = threadId;\n        setTranscript({ ...emptyTranscript(), threadId });\n      }\n      // Re-hydrate the learned OM facts (resource-scoped) onto the fresh transcript.\n      refreshMemory();\n    },\n    [refreshMemory],\n  );\n\n  /** Clear the transcript and start a brand-new conversation (server mints a thread). */\n  const reset = useCallback(() => {\n    threadRef.current = null;\n    setStatus('ready');\n    setTranscript(emptyTranscript());\n    // OM facts are resource-scoped, so they still apply in a brand-new chat.\n    refreshMemory();\n  }, [refreshMemory]);\n\n  /** Clear the active thread's objective (the agent stops goal-driven looping). */\n  const clearGoal = useCallback(async () => {\n    setTranscript((s) => ({ ...s, goal: null }));\n    try {\n      await fetch('/api/agent-controller/goal', { method: 'DELETE' });\n    } catch {\n      // The optimistic clear stands even if the server call fails.\n    }\n  }, []);\n\n  return {\n    transcript,\n    status,\n    sendMessage,\n    approve,\n    /** Answer a parked `ask_user` prompt (string, or string[] for multi-select). */\n    answerQuestion,\n    /** The current `ask_user` prompt awaiting an answer (null when none). */\n    pendingSuspension: transcript.pendingSuspension,\n    clearTerminal,\n    openThread,\n    reset,\n    /** The current goal-run state (null when no objective is set). Set by the agent's own\n     *  `setGoal` tool and updated by `goal_evaluation` events; the UI only reads it. */\n    goal: transcript.goal,\n    /** Clear the active objective (backs the goal card's dismiss control). */\n    clearGoal,\n    /** Observational-Memory state (token windows + activity), folded from `om_*`. */\n    memory: transcript.memory,\n    /** The agent's recurring schedules (read-only; refetched when a run settles). */\n    schedules,\n    /** Force a refetch of the schedules list (e.g. after the schedules tab opens). */\n    refreshSchedules,\n    /** The active conversation id (null before the first turn / after reset). */\n    activeThreadId: transcript.threadId,\n    /** Increments when a turn completes — drives the sidebar refetch. */\n    refreshSignal,\n  };\n}\n\n/** The shared controller transport, lifted to the shell so chat + workbench panel drive one session. */\nexport type UseAgentControllerChat = ReturnType<typeof useAgentControllerChat>;\n",
      "type": "registry:lib",
      "target": "lib/agent-controller/use-agent-controller-chat.ts"
    },
    {
      "path": "lib/agent-controller/use-threads.ts",
      "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\n/**\n * Conversation-history data for a chat shell — the `/api/agent-controller/threads*`\n * surface, owned by the engine rather than by any one view.\n *\n * This lives here, not in the sidebar component, so a second chat skin gets thread\n * listing, semantic search, rename/archive/delete and the optimistic-update +\n * rollback behaviour for free instead of reimplementing it (and drifting from the\n * first skin). See `bd mastra-chat-kit-h27`.\n *\n * Deliberately UI-free: no toast, no icons, no components. Mutations resolve to a\n * boolean so the SKIN decides how success and failure are shown — keeping this\n * module dependency-free on anything visual is what makes skins swappable at all.\n */\n\nexport type ThreadItem = {\n  id: string;\n  title: string;\n  archived: boolean;\n  createdAt: string;\n  updatedAt: string;\n};\n\nexport type SearchHit = { id: string; title: string; snippet: string; score: number };\n\n/** Debounce any fast-changing value (the search input) without an extra dependency. */\nexport function useDebounced<T>(value: T, ms: number): T {\n  const [debounced, setDebounced] = useState(value);\n  useEffect(() => {\n    const t = setTimeout(() => setDebounced(value), ms);\n    return () => clearTimeout(t);\n  }, [value, ms]);\n  return debounced;\n}\n\n/**\n * @param refreshSignal bump to force a refetch — `useAgentControllerChat` increments\n * one each time a turn settles, so a new thread (and its generated title) appears.\n */\nexport function useThreads({ refreshSignal = 0 }: { refreshSignal?: number } = {}) {\n  const [threads, setThreads] = useState<ThreadItem[]>([]);\n\n  const [search, setSearch] = useState('');\n  const debouncedSearch = useDebounced(search.trim(), 250);\n  const isSearching = debouncedSearch.length >= 2;\n  const [searchHits, setSearchHits] = useState<SearchHit[]>([]);\n  const [searching, setSearching] = useState(false);\n\n  const refresh = useCallback(async () => {\n    try {\n      const res = await fetch('/api/agent-controller/threads', { cache: 'no-store' });\n      const data = (await res.json()) as { threads?: ThreadItem[] };\n      setThreads(data.threads ?? []);\n    } catch {\n      // History is non-critical; a failed refresh just keeps the last list.\n    }\n  }, []);\n\n  // Reload on mount and whenever a turn finishes upstream.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: refreshSignal is an intentional refetch trigger, not read in the body.\n  useEffect(() => {\n    void refresh();\n  }, [refresh, refreshSignal]);\n\n  // Debounced semantic search over message bodies (server-side fastembed).\n  useEffect(() => {\n    if (!isSearching) {\n      setSearchHits([]);\n      return;\n    }\n    let cancelled = false;\n    setSearching(true);\n    fetch(`/api/agent-controller/threads/search?q=${encodeURIComponent(debouncedSearch)}`, {\n      cache: 'no-store',\n    })\n      .then((r) => r.json())\n      .then((data: { threads?: SearchHit[] }) => {\n        if (!cancelled) setSearchHits(data.threads ?? []);\n      })\n      .catch(() => {\n        if (!cancelled) setSearchHits([]);\n      })\n      .finally(() => {\n        if (!cancelled) setSearching(false);\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [debouncedSearch, isSearching]);\n\n  const active = useMemo(() => threads.filter((t) => !t.archived), [threads]);\n  const archived = useMemo(() => threads.filter((t) => t.archived), [threads]);\n\n  /** PATCH a thread, applying `optimistic` immediately and rolling back on failure. */\n  const patch = useCallback(\n    async (id: string, body: Record<string, unknown>, optimistic: Partial<ThreadItem>) => {\n      setThreads((prev) => prev.map((x) => (x.id === id ? { ...x, ...optimistic } : x)));\n      try {\n        const res = await fetch(`/api/agent-controller/threads/${id}`, {\n          method: 'PATCH',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify(body),\n        });\n        if (!res.ok) throw new Error(String(res.status));\n        return true;\n      } catch {\n        void refresh();\n        return false;\n      }\n    },\n    [refresh],\n  );\n\n  /** Archive or restore. Call with `false` to undo an archive. */\n  const archive = useCallback(\n    (t: ThreadItem, nextArchived: boolean) =>\n      patch(t.id, { archived: nextArchived }, { archived: nextArchived }),\n    [patch],\n  );\n\n  /** Rename. No-ops (resolving true) when the title is blank or unchanged. */\n  const rename = useCallback(\n    async (t: ThreadItem, title: string) => {\n      const next = title.trim();\n      if (!next || next === t.title) return true;\n      return patch(t.id, { title: next }, { title: next });\n    },\n    [patch],\n  );\n\n  const remove = useCallback(\n    async (t: ThreadItem) => {\n      setThreads((prev) => prev.filter((x) => x.id !== t.id));\n      try {\n        const res = await fetch(`/api/agent-controller/threads/${t.id}`, { method: 'DELETE' });\n        if (!res.ok) throw new Error(String(res.status));\n        return true;\n      } catch {\n        void refresh();\n        return false;\n      }\n    },\n    [refresh],\n  );\n\n  return {\n    threads,\n    active,\n    archived,\n    search,\n    setSearch,\n    debouncedSearch,\n    isSearching,\n    searchHits,\n    searching,\n    refresh,\n    archive,\n    rename,\n    remove,\n  };\n}\n\nexport type UseThreads = ReturnType<typeof useThreads>;\n",
      "type": "registry:lib",
      "target": "lib/agent-controller/use-threads.ts"
    },
    {
      "path": "lib/agent-controller/use-workspace.ts",
      "content": "'use client';\n\nimport { useCallback, useEffect, useState } from 'react';\n\n/**\n * The agent's workspace as data — `/api/workspace/*` (the real `WORKSPACE_ROOT`\n * directory on the server) plus the generated-image store.\n *\n * Owned by the engine, not by the Files panel, so a second chat skin can surface\n * workspace contents without reimplementing the polling-while-streaming behaviour.\n * See `bd mastra-chat-kit-h27`.\n *\n * UI-free by design: returns data and loading flags, renders nothing.\n */\n\nexport type FileNode = {\n  name: string;\n  path: string;\n  type: 'file' | 'dir';\n  children?: FileNode[];\n};\n\n/** Flatten a tree to the set of paths that are FILES (folders are not readable). */\nexport function collectFilePaths(nodes: FileNode[], acc = new Set<string>()): Set<string> {\n  for (const n of nodes) {\n    if (n.type === 'file') acc.add(n.path);\n    if (n.children) collectFilePaths(n.children, acc);\n  }\n  return acc;\n}\n\n/**\n * @param status the controller's run status — while `streaming`, the tree polls so\n * files the agent writes appear live, then reloads once more on settle.\n */\nexport function useWorkspaceFiles({ status }: { status?: string } = {}) {\n  const [tree, setTree] = useState<FileNode[]>([]);\n  const [loadingTree, setLoadingTree] = useState(false);\n  const [selected, setSelected] = useState<string | null>(null);\n  const [content, setContent] = useState<string | null>(null);\n  const [loadingFile, setLoadingFile] = useState(false);\n\n  const loadTree = useCallback(async () => {\n    setLoadingTree(true);\n    try {\n      const res = await fetch('/api/workspace/files');\n      if (res.ok) {\n        const data = (await res.json()) as { tree?: FileNode[] };\n        setTree(data.tree ?? []);\n      }\n    } catch {\n      // best-effort; leave the previous tree in place\n    } finally {\n      setLoadingTree(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    void loadTree();\n  }, [loadTree]);\n\n  // While the agent runs, poll so its writes appear live; the cleanup also fires the\n  // final reload on the streaming→ready transition, so the tree settles on the\n  // finished state. No manual refresh to think about.\n  useEffect(() => {\n    if (status !== 'streaming') return;\n    const id = setInterval(loadTree, 2000);\n    return () => {\n      clearInterval(id);\n      void loadTree();\n    };\n  }, [status, loadTree]);\n\n  /** Select a path and load its text. Ignores folders (FileTree fires for those too). */\n  const selectPath = useCallback(\n    async (p: string) => {\n      if (!collectFilePaths(tree).has(p)) return;\n      setSelected(p);\n      setContent(null);\n      setLoadingFile(true);\n      try {\n        const res = await fetch(`/api/workspace/file?path=${encodeURIComponent(p)}`);\n        setContent(res.ok ? ((await res.json()) as { content: string }).content : null);\n      } catch {\n        setContent(null);\n      } finally {\n        setLoadingFile(false);\n      }\n    },\n    [tree],\n  );\n\n  /** Close the open file (back to just the tree). */\n  const closeFile = useCallback(() => {\n    setSelected(null);\n    setContent(null);\n  }, []);\n\n  return { tree, loadingTree, loadTree, selected, content, loadingFile, selectPath, closeFile };\n}\n\nexport type UseWorkspaceFiles = ReturnType<typeof useWorkspaceFiles>;\n\n/**\n * Resolve a generated image by id. `generateImage` returns only a tiny `imageId`\n * (a full base64 would overflow the model context), so the bytes are fetched from\n * `/api/images/:id` at render time. Pass `base64` directly to skip the fetch.\n */\nexport function useGeneratedImage({\n  imageId,\n  base64,\n  mediaType,\n}: {\n  imageId?: string;\n  base64?: string;\n  mediaType: string;\n}) {\n  const [data, setData] = useState<{ base64: string; mediaType: string } | null>(\n    base64 ? { base64, mediaType } : null,\n  );\n\n  useEffect(() => {\n    if (data || !imageId) return;\n    let active = true;\n    fetch(`/api/images/${imageId}`)\n      .then((r) => (r.ok ? r.json() : null))\n      .then((d) => {\n        if (active && d?.base64) {\n          setData({ base64: d.base64, mediaType: d.mediaType ?? mediaType });\n        }\n      })\n      .catch(() => {});\n    return () => {\n      active = false;\n    };\n  }, [imageId, data, mediaType]);\n\n  return data;\n}\n",
      "type": "registry:lib",
      "target": "lib/agent-controller/use-workspace.ts"
    }
  ],
  "type": "registry:lib"
}