diff --git a/apps/cli/src/app.tsx b/apps/cli/src/app.tsx index eee5b8a..e595aab 100644 --- a/apps/cli/src/app.tsx +++ b/apps/cli/src/app.tsx @@ -18,6 +18,7 @@ import { HintPill } from "./components/HintPill"; import { InputBox } from "./components/InputBox"; import { MessageView } from "./components/MessageView"; import { Notification } from "./components/Notification"; +import { TaskPanel } from "./components/TaskPanel"; import { WelcomeBanner } from "./components/WelcomeBanner"; import { useCurrentThreadHasChild, @@ -36,6 +37,7 @@ import { useSetPendingRequestId, useSetRun, useSetStatus, + useSetTasks, useStatus } from "./hooks/use-app"; import { dispatchKey } from "./keymap/dispatch"; @@ -141,6 +143,7 @@ export function App({ const setIsCompacting = useSetIsCompacting(); const setCurrentThreadHasChild = useSetCurrentThreadHasChild(); const setPendingConflict = useSetPendingConflict(); + const setTasks = useSetTasks(); // Per-slice subscription so App only re-renders when the conflict frame // appears/disappears, not on every other store update. @@ -186,6 +189,7 @@ export function App({ setNotice, setIsCompacting, setPendingConflict, + setTasks, send: (raw) => agentRef.current?.send(raw), switchThread }); @@ -198,6 +202,7 @@ export function App({ setNotice, setIsCompacting, setPendingConflict, + setTasks, switchThread ] ) @@ -240,6 +245,8 @@ export function App({ setPendingRequestId(null); setFocusedToolCallId(null); setIsCompacting(false); + // Clear before reconnect; kernel's onConnect will re-broadcast. + setTasks([]); // Optimistic: assume the freshly-selected thread is live. The /threads // fetch below corrects this if the user is actually viewing an archive. setCurrentThreadHasChild(false); @@ -250,6 +257,7 @@ export function App({ setPendingRequestId, setFocusedToolCallId, setIsCompacting, + setTasks, setCurrentThreadHasChild ]); @@ -480,6 +488,9 @@ export function App({ onClose={() => setActivePanel(null)} /> ) : null} + + + {pendingConflict ? ( ) : inApprovalMode ? ( diff --git a/apps/cli/src/components/TaskPanel.tsx b/apps/cli/src/components/TaskPanel.tsx new file mode 100644 index 0000000..172dcef --- /dev/null +++ b/apps/cli/src/components/TaskPanel.tsx @@ -0,0 +1,44 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; + +import type { TaskRow } from "@agent-os/protocol"; + +import { useStore } from "../store"; + +// Status-to-glyph mapping. Keep in sync with packages/models task.status enum. +// `?` is the defensive fallback if a future status enum value reaches the TUI +// before this map is updated. +const STATUS_GLYPH: Record = + { + pending: { icon: "☐", color: "gray" }, + in_progress: { icon: "▶", color: "yellow" }, + complete: { icon: "✓", color: "green" }, + failed: { icon: "✗", color: "red" }, + cancelled: { icon: "⊘", color: "gray" } + }; + +function TaskPanelImpl() { + // Selector subscribes only this component to `tasks` — App stays silent on + // task changes. Matches the LiveRow optimization pattern: per-field + // subscription prevents per-frame re-renders of the wider tree. + const tasks = useStore((s) => s.tasks); + + if (tasks.length === 0) return null; + + return ( + + Tasks ({tasks.length}) + {tasks.map((t) => { + const g = STATUS_GLYPH[t.status] ?? { icon: "?", color: "gray" }; + return ( + + {g.icon} + {t.name} + + ); + })} + + ); +} + +export const TaskPanel = memo(TaskPanelImpl); diff --git a/apps/cli/src/components/tools/SpawnSubAgentTool.tsx b/apps/cli/src/components/tools/SpawnSubAgentTool.tsx new file mode 100644 index 0000000..fcedd0a --- /dev/null +++ b/apps/cli/src/components/tools/SpawnSubAgentTool.tsx @@ -0,0 +1,98 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; + +import type { ToolRendererProps } from "./registry"; + +interface SpawnSubAgentInput { + model: "opus" | "sonnet" | "haiku"; + prompt: string; + task_id?: string; +} + +interface SpawnSubAgentOutput { + runId: string; + finalText: string; +} + +const MODEL_COLOR: Record = { + opus: "magenta", + sonnet: "blue", + haiku: "green" +}; + +function SpawnSubAgentToolImpl({ part, focused }: ToolRendererProps) { + const input = part.input as Partial | undefined; + const output = part.output as SpawnSubAgentOutput | undefined; + const model: SpawnSubAgentInput["model"] = input?.model ?? "opus"; + const modelColor = MODEL_COLOR[model]; + const prompt = input?.prompt ?? ""; + const taskId = input?.task_id; + + const isRunning = part.state === "input-available"; + + // Failure state takes precedence over `state` — errorText is set on any + // terminal-failure path (abort, throw, model rejection). Also fold + // `output-error` in here: format.ts treats it as equivalent, and without + // this check an `output-error` with empty errorText would fall through + // to `null`. + if (part.errorText || part.state === "output-error") { + return ( + + + ✗ Sub-agent ( + {model}) · failed + + {part.errorText ?? "Sub-agent failed"} + + ); + } + + if (part.state === "input-streaming") { + return ( + + ⏳ Sub-agent ( + {model}) … + + ); + } + + if (isRunning) { + return ( + + + 🤖 Sub-agent ( + {model}) · running + + {prompt ? "{prompt}" : null} + + ); + } + + // state === "output-available" + if (!output) return null; + // Approximation: ~4 chars per token. Marked with `~` in the UI. + const approxTok = Math.ceil(output.finalText.length / 4); + + return ( + + + ✓ Sub-agent ( + {model}) · ~{approxTok} tok + + {prompt ? "{prompt}" : null} + {taskId ? → task: {taskId} : null} + {focused ? ( + + {output.finalText} + + ) : null} + + ); +} + +export const SpawnSubAgentTool = memo(SpawnSubAgentToolImpl); diff --git a/apps/cli/src/components/tools/registry.ts b/apps/cli/src/components/tools/registry.ts index a31665c..20555de 100644 --- a/apps/cli/src/components/tools/registry.ts +++ b/apps/cli/src/components/tools/registry.ts @@ -6,10 +6,11 @@ import { FindFilesTool } from "./FindFilesTool"; import { GenericTool } from "./GenericTool"; import { ListFilesTool } from "./ListFilesTool"; import { ReadTool } from "./ReadTool"; +import { SpawnSubAgentTool } from "./SpawnSubAgentTool"; +import type { ToolPart } from "./types"; import { WebFetchTool } from "./WebFetchTool"; import { WebSearchTool } from "./WebSearchTool"; import { WriteTool } from "./WriteTool"; -import type { ToolPart } from "./types"; export type { ToolPart }; @@ -29,7 +30,8 @@ export const renderers: Record> = { web_search: WebSearchTool, web_fetch: WebFetchTool, computer_bash: BashTool, - exec_code: ExecCodeTool + exec_code: ExecCodeTool, + spawn_sub_agent: SpawnSubAgentTool }; export function pickRenderer(toolName: string): FC { diff --git a/apps/cli/src/hooks/use-app.ts b/apps/cli/src/hooks/use-app.ts index 46b11a8..55523f2 100644 --- a/apps/cli/src/hooks/use-app.ts +++ b/apps/cli/src/hooks/use-app.ts @@ -36,3 +36,4 @@ export const useSetCurrentThreadHasChild = () => export const useSetPendingConflict = () => useStore((s) => s.setPendingConflict); export const useSetSend = () => useStore((s) => s.setSend); +export const useSetTasks = () => useStore((s) => s.setTasks); diff --git a/apps/cli/src/protocol/handle-frame.ts b/apps/cli/src/protocol/handle-frame.ts index e5031f0..13aeea7 100644 --- a/apps/cli/src/protocol/handle-frame.ts +++ b/apps/cli/src/protocol/handle-frame.ts @@ -6,6 +6,7 @@ import { COMPACTION_STATUS, MERGE_CONFLICT, RUN_STATUS_CHANGED, + TASKS_UPDATED, THREAD_COMPACTED, THREAD_FROZEN, USER_MESSAGE_REJECTED, @@ -13,6 +14,7 @@ import { type CompactionStatusFrame, type MergeConflictFrame, type RunStatusChangedFrame, + type TasksUpdatedFrame, type ThreadCompactedFrame, type ThreadFrozenFrame, type UserMessageRejectedFrame @@ -40,6 +42,7 @@ export type ProtocolActions = Pick< | "setNotice" | "setIsCompacting" | "setPendingConflict" + | "setTasks" > & { applyStreamChunk: (chunk: { type: string; [k: string]: unknown }) => void; send: (raw: string) => void; @@ -158,6 +161,12 @@ export function handleFrame(raw: unknown, actions: ProtocolActions): void { return; } + case TASKS_UPDATED: { + const f = frame as TasksUpdatedFrame; + actions.setTasks(f.tasks); + return; + } + case THREAD_COMPACTED: { const f = frame as unknown as ThreadCompactedFrame; actions.setIsCompacting(false); diff --git a/apps/cli/src/store/index.ts b/apps/cli/src/store/index.ts index 40016c1..7b9d781 100644 --- a/apps/cli/src/store/index.ts +++ b/apps/cli/src/store/index.ts @@ -6,7 +6,8 @@ import { type Attachment, type MergeConflictFrame, type MergeConflictResolutionFrame, - type RunStatus + type RunStatus, + type TaskRow } from "@agent-os/protocol"; import type { ApprovalEntry } from "../keymap/dispatch"; @@ -186,6 +187,12 @@ type Store = { currentThreadHasChild: boolean; setCurrentThreadHasChild: (v: boolean) => void; + // Per-thread task rows from the kernel. Pushed via TASKS_UPDATED frames + // whenever a tool or the kernel itself mutates the thread's task list. + // Consumed by TaskPanel (next task). + tasks: TaskRow[]; + setTasks: (tasks: TaskRow[]) => void; + // Outbound WebSocket send. Wired up by App once the agent connection is // live, so store actions (sendConflictResolution / sendConflictAbort) can // emit frames without prop-drilling the agent down to every consumer. @@ -431,6 +438,9 @@ export const useStore = create((set, get) => ({ currentThreadHasChild: false, setCurrentThreadHasChild: (v) => set({ currentThreadHasChild: v }), + tasks: [], + setTasks: (tasks) => set({ tasks }), + // Default no-op until App wires the real agent.send. Calling before wiring // is a programming error — the conflict UI only opens after a frame has // arrived, by which point the socket is already up. diff --git a/apps/kernel/src/broadcasts.ts b/apps/kernel/src/broadcasts.ts index 8400330..1e00603 100644 --- a/apps/kernel/src/broadcasts.ts +++ b/apps/kernel/src/broadcasts.ts @@ -1,12 +1,16 @@ -import { type Connection } from "agents"; import { type OutgoingMessage } from "@cloudflare/ai-chat/types"; +import { type Connection } from "agents"; +import { asc, eq, schema } from "@agent-os/models"; import { RUN_STATUS_CHANGED, + TASKS_UPDATED, type CompactRejectedFrame, type CompactionStatusFrame, type RunStatus, type RunStatusChangedFrame, + type TaskRow, + type TasksUpdatedFrame, type ThreadCompactedFrame, type ThreadFrozenFrame, type UserMessageRejectedFrame @@ -21,7 +25,8 @@ type Outbound = | ThreadCompactedFrame | ThreadFrozenFrame | CompactRejectedFrame - | UserMessageRejectedFrame; + | UserMessageRejectedFrame + | TasksUpdatedFrame; // Send a single frame to one connection. Wrapper exists for the typed // `msg` union — without it, ad-hoc `JSON.stringify` calls drift in shape. @@ -63,3 +68,35 @@ export function broadcastRunStatus( ...(messageId ? { messageId } : {}) }); } + +// Query the thread's current task list and fan it out to every connection +// watching the thread. Called from three sites: +// - kernel.onConnect (initial state seed) +// - tools/tasks.ts execute() (after manage_tasks mutations) +// - tools/spawn-sub-agent.ts (after writing task.result) +// +// Full-snapshot rather than delta: simpler client, ordered WS makes it +// safe, and the row count per thread is always tiny. +export async function broadcastTasksUpdated( + kernel: Kernel, + threadId: string +): Promise { + const rows = await kernel.db.query.task.findMany({ + where: eq(schema.task.threadId, threadId), + orderBy: [asc(schema.task.createdAt)] + }); + const tasks: TaskRow[] = rows.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + status: t.status, + result: t.result, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString() + })); + broadcastToThread(kernel, threadId, { + type: TASKS_UPDATED, + threadId, + tasks + }); +} diff --git a/apps/kernel/src/kernel.ts b/apps/kernel/src/kernel.ts index d31478f..c480566 100644 --- a/apps/kernel/src/kernel.ts +++ b/apps/kernel/src/kernel.ts @@ -35,7 +35,7 @@ import { type RunRetryFrame } from "@agent-os/protocol"; -import { send } from "./broadcasts"; +import { broadcastTasksUpdated, send } from "./broadcasts"; import { ensureMemoryScaffold } from "./fs/memory-scaffold"; import { handleCancel, @@ -585,6 +585,20 @@ export class Kernel extends Agent { messages: this._rowsToUI(rows) }); + // Seed task-panel state for the freshly connected client. Uses the + // fan-out helper rather than `send` to one connection because a second + // tab opening on the same thread should re-trigger the broadcast to + // keep siblings in sync (idempotent — frame is a full snapshot). + // Best-effort: a broadcast failure must not close the WS. + try { + await broadcastTasksUpdated(this, threadId); + } catch (e) { + console.error("[onConnect] broadcastTasksUpdated failed", { + threadId, + error: e instanceof Error ? e.message : String(e) + }); + } + // Proactive resume notification: if a stream is mid-flight, tell the // new connection right away so it can ACK and start receiving replay // frames. Clients that prefer the explicit-request pattern (registering diff --git a/apps/kernel/src/tools/spawn-sub-agent.ts b/apps/kernel/src/tools/spawn-sub-agent.ts index 7d1f35e..33ab4d9 100644 --- a/apps/kernel/src/tools/spawn-sub-agent.ts +++ b/apps/kernel/src/tools/spawn-sub-agent.ts @@ -6,6 +6,7 @@ import { and, desc, eq, schema, ulid, type DB, type Task } from "@agent-os/model import { buildSubAgentContextMessages } from "../agent/sub-agent-context-messages"; import { SUB_AGENT_SYSTEM_PROMPT } from "../agent/sub-agent-system-prompt"; +import { broadcastTasksUpdated } from "../broadcasts"; import { projectMessageText } from "../utils/project-message-text"; import { buildSubAgentTools } from "./index"; import { wrappedTool, type PerTurnContext } from "./wrapped-tool"; @@ -329,6 +330,18 @@ export function buildSpawnSubAgentTool(perTurn: PerTurnContext) { .update(schema.task) .set({ result: finalText }) .where(eq(schema.task.id, args.task_id)); + + // Refresh the panel — task.result changed without going through + // manage_tasks, so the panel's cached state is now stale. + // Best-effort: a broadcast failure must not fail the tool call. + try { + await broadcastTasksUpdated(ctx.kernel, ctx.threadId); + } catch (e) { + console.error("[spawn_sub_agent] broadcastTasksUpdated failed", { + threadId: ctx.threadId, + error: e instanceof Error ? e.message : String(e) + }); + } } return { runId: subRunId, finalText }; diff --git a/apps/kernel/src/tools/tasks.ts b/apps/kernel/src/tools/tasks.ts index 0c61a52..15770cd 100644 --- a/apps/kernel/src/tools/tasks.ts +++ b/apps/kernel/src/tools/tasks.ts @@ -1,6 +1,7 @@ import { asc, eq, schema, ulid } from "@agent-os/models"; import { z } from "zod"; +import { broadcastTasksUpdated } from "../broadcasts"; import { wrappedTool, type PerTurnContext } from "./wrapped-tool"; const TASK_STATUS = [ @@ -139,6 +140,20 @@ export function buildTasksTools(perTurn: PerTurnContext) { updatedAt: t.updatedAt.toISOString() })) }; + + // Push the same list to the panel. Re-queries the DB inside the helper + // rather than re-using `tasks` above — keeps formatting (Date → ISO) in + // one place. One extra indexed read against a tiny table; not hot. + // Best-effort: a broadcast failure must not fail the tool call. + try { + await broadcastTasksUpdated(ctx.kernel, ctx.threadId); + } catch (e) { + console.error("[manage_tasks] broadcastTasksUpdated failed", { + threadId: ctx.threadId, + error: e instanceof Error ? e.message : String(e) + }); + } + return result; } }, diff --git a/docs/superpowers/plans/2026-05-15-tui-tasks-and-subagent.md b/docs/superpowers/plans/2026-05-15-tui-tasks-and-subagent.md new file mode 100644 index 0000000..c74336d --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-tui-tasks-and-subagent.md @@ -0,0 +1,903 @@ +# PR-E: TUI Task Panel + Sub-Agent Renderer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface PR-C (tasks) and PR-D (sub-agents) backend state in the Ink TUI — a compact task panel above the input box, and a custom renderer for `spawn_sub_agent` tool calls. + +**Architecture:** New `TASKS_UPDATED` WS frame carries the full task array per thread. Kernel pushes the frame on three triggers (onConnect, `manage_tasks` execute, `spawn_sub_agent` task.result write). TUI consumes via `handle-frame.ts` → Zustand store → `` rendered in the live region. `SpawnSubAgentTool` slots into the existing tool-renderer registry with a state-machine view (in-flight → collapsed → focus-to-expand → failed). + +**Tech Stack:** TypeScript, Ink (React), Zustand (TUI store), Drizzle ORM + SQLite (DO storage), Cloudflare Durable Objects (kernel), `@cloudflare/ai-chat` WebSocket protocol. + +**Reference spec:** `docs/superpowers/specs/2026-05-15-tui-tasks-and-subagent-design.md` + +--- + +## House-keeping rules (apply to every task) + +- **No PR-letter markers.** Do not add `// PR-E`, `// NEW (PR-E)`, `// MODIFIED` etc. Comments describe what code does or why, not which slice it landed in. Strip any leftover `// PR-A/B/C/D` markers found while editing touched files (a pre-flight scan on 2026-05-15 found none in PR-E's touched files, but stay alert). +- **Lint/format:** run `pnpm check` (oxfmt + oxlint + turbo typecheck) before each commit. Fix any errors before committing. +- **Subagents:** when using subagent-driven-development, use `opus` for all subagents. +- **No new unit tests.** This is a UI / wiring PR; the smoke checklist (Task 7) is the verification surface. Don't add Vitest/Jest cases. +- **Commit per task.** One conventional-commit message per task. Co-author trailer per `~/.claude/CLAUDE.md`. + +--- + +## File map + +### Created + +| Path | Responsibility | +|---|---| +| `apps/cli/src/components/TaskPanel.tsx` | Compact task list rendered above input; auto-hides when empty. | +| `apps/cli/src/components/tools/SpawnSubAgentTool.tsx` | Custom renderer for the `spawn_sub_agent` tool with full state machine. | + +### Modified + +| Path | Change | +|---|---| +| `packages/protocol/src/index.ts` | Add `TASKS_UPDATED` constant, `TaskRow` interface, `TasksUpdatedFrame` type, extend `KernelToClient` union. | +| `apps/kernel/src/broadcasts.ts` | Add `broadcastTasksUpdated()` helper. Extend `Outbound` union with `TasksUpdatedFrame`. | +| `apps/kernel/src/kernel.ts` | `onConnect` calls `broadcastTasksUpdated` after `CF_AGENT_CHAT_MESSAGES`. | +| `apps/kernel/src/tools/tasks.ts` | `manage_tasks` execute calls `broadcastTasksUpdated` before returning. | +| `apps/kernel/src/tools/spawn-sub-agent.ts` | After `task.result` write, call `broadcastTasksUpdated`. | +| `apps/cli/src/store/index.ts` | Add `tasks: TaskRow[]` slice + `setTasks` action. | +| `apps/cli/src/protocol/handle-frame.ts` | Import `TASKS_UPDATED`, dispatch to `setTasks`, extend `ProtocolActions` Pick. | +| `apps/cli/src/app.tsx` | Mount `` between in-flight row and input. | +| `apps/cli/src/components/tools/registry.ts` | Register `spawn_sub_agent: SpawnSubAgentTool`. | + +--- + +## Task 1: Protocol — `TASKS_UPDATED` frame definition + +**Files:** +- Modify: `packages/protocol/src/index.ts` + +The protocol package is the source of truth for wire types. Both kernel and TUI import from here. + +- [ ] **Step 1: Add the new frame constant + types** + +Open `packages/protocol/src/index.ts`. Find the existing compaction frames constant block (around lines 46-51). Append the new constant. Then add the new types after the existing `UserMessageRejectedFrame` type definition. + +Add this constant alongside `USER_MESSAGE_REJECTED`: + +```ts +// Tasks panel state. Kernel pushes the full task list for the connected +// thread on three triggers: onConnect (initial seed), manage_tasks execute +// (state change), spawn_sub_agent (task.result write). DB is the source of +// truth; the frame is delivery. Snapshot rather than delta — receiver +// replaces state wholesale on each frame. Empty `tasks` is valid and means +// the panel should hide. +export const TASKS_UPDATED = "agent_os.tasks.updated" as const; +``` + +Add the row type. Place it after the `UserMessageRejectedFrame` type: + +```ts +// One task as wire-rendered. Mirrors TaskJSON from kernel/src/tools/tasks.ts +// but re-defined here so the TUI doesn't depend on the kernel package. +export interface TaskRow { + id: string; + name: string; + description: string; + status: "pending" | "in_progress" | "complete" | "failed" | "cancelled"; + result: string | null; + /** ISO 8601 */ + createdAt: string; + /** ISO 8601 */ + updatedAt: string; +} + +export type TasksUpdatedFrame = { + type: typeof TASKS_UPDATED; + threadId: string; + /** Full list, sorted by createdAt ASC. */ + tasks: TaskRow[]; +}; +``` + +- [ ] **Step 2: Add `TasksUpdatedFrame` to the `KernelToClient` union** + +Find the `KernelToClient` union (currently lines 165-174). Add `TasksUpdatedFrame` as the last entry: + +```ts +export type KernelToClient = + | { type: "pong" } + | { type: "error"; message: string } + | RunStatusChangedFrame + | MergeConflictFrame + | CompactionStatusFrame + | ThreadCompactedFrame + | ThreadFrozenFrame + | CompactRejectedFrame + | UserMessageRejectedFrame + | TasksUpdatedFrame; +``` + +- [ ] **Step 3: Verify + commit** + +Run from repo root: + +```bash +pnpm check +``` + +Expected: no errors. The new type is additive; nothing else imports from the protocol package's union shape yet. + +Commit: + +```bash +git add packages/protocol/src/index.ts +git commit -m "feat(protocol): TASKS_UPDATED frame for task panel state + +Adds the frame type, TaskRow row shape, and KernelToClient union +extension. Wire definition only — no kernel or TUI consumers yet +(those land in subsequent tasks). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Kernel — `broadcastTasksUpdated` helper + +**Files:** +- Modify: `apps/kernel/src/broadcasts.ts` + +One helper, three trigger sites in subsequent tasks. The helper owns the "query DB → format → broadcast" path so individual triggers stay one-liners. + +- [ ] **Step 1: Add imports** + +Open `apps/kernel/src/broadcasts.ts`. The current imports (lines 1-13) cover the existing protocol types and `Connection`. Add the new ones at the top of the import blocks. Replace lines 1-13 with: + +```ts +import { type Connection } from "agents"; +import { type OutgoingMessage } from "@cloudflare/ai-chat/types"; +import { asc, eq, schema } from "@agent-os/models"; + +import { + RUN_STATUS_CHANGED, + TASKS_UPDATED, + type CompactRejectedFrame, + type CompactionStatusFrame, + type RunStatus, + type RunStatusChangedFrame, + type TaskRow, + type TasksUpdatedFrame, + type ThreadCompactedFrame, + type ThreadFrozenFrame, + type UserMessageRejectedFrame +} from "@agent-os/protocol"; + +import type { ConnectionState, Kernel } from "./kernel"; +``` + +- [ ] **Step 2: Extend the `Outbound` union** + +Find the `Outbound` type (currently lines 17-24). Replace with: + +```ts +type Outbound = + | OutgoingMessage + | RunStatusChangedFrame + | CompactionStatusFrame + | ThreadCompactedFrame + | ThreadFrozenFrame + | CompactRejectedFrame + | UserMessageRejectedFrame + | TasksUpdatedFrame; +``` + +- [ ] **Step 3: Add `broadcastTasksUpdated` helper** + +Append the new helper at the end of the file (after `broadcastRunStatus`): + +```ts +// Query the thread's current task list and fan it out to every connection +// watching the thread. Called from three sites: +// - kernel.onConnect (initial state seed) +// - tools/tasks.ts execute() (after manage_tasks mutations) +// - tools/spawn-sub-agent.ts (after writing task.result) +// +// Full-snapshot rather than delta: simpler client, ordered WS makes it +// safe, and the row count per thread is always tiny. +export async function broadcastTasksUpdated( + kernel: Kernel, + threadId: string +): Promise { + const rows = await kernel.db.query.task.findMany({ + where: eq(schema.task.threadId, threadId), + orderBy: [asc(schema.task.createdAt)] + }); + const tasks: TaskRow[] = rows.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + status: t.status, + result: t.result, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString() + })); + broadcastToThread(kernel, threadId, { + type: TASKS_UPDATED, + threadId, + tasks + }); +} +``` + +- [ ] **Step 4: Verify + commit** + +```bash +pnpm check +``` + +Expected: no errors. `kernel.db` is the DO's drizzle handle (declared at `kernel.ts:110`). + +```bash +git add apps/kernel/src/broadcasts.ts +git commit -m "feat(kernel): broadcastTasksUpdated helper + +Queries schema.task by threadId, maps to TaskRow, fans out via +broadcastToThread. Single source of truth for task-panel state +on the wire. No callers yet — they land in the next task. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Kernel — wire `broadcastTasksUpdated` into the three trigger sites + +**Files:** +- Modify: `apps/kernel/src/kernel.ts` (onConnect) +- Modify: `apps/kernel/src/tools/tasks.ts` (manage_tasks execute) +- Modify: `apps/kernel/src/tools/spawn-sub-agent.ts` (task.result write) + +Three single-line additions, all importing the same helper. Done in one task because they share the same pattern. + +- [ ] **Step 1: Wire `onConnect` (initial state seed)** + +Open `apps/kernel/src/kernel.ts`. Find the `onConnect` method (line 566). The existing `send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, ... })` is around lines 583-586. Add the broadcast right after that block, before the `_resumableStream` check. + +Add the import for `broadcastTasksUpdated`. Find the existing import from `./broadcasts` (search for `from "./broadcasts"`). Extend it: + +```ts +import { + broadcastRunStatus, + broadcastTasksUpdated, + broadcastToThread, + send +} from "./broadcasts"; +``` + +(Preserve whichever other names the file already imports — add `broadcastTasksUpdated` to the list. The above is the likely shape; if the file imports differently, adapt.) + +Then in `onConnect`, after the `CF_AGENT_CHAT_MESSAGES` send: + +```ts +send(connection, { + type: MessageType.CF_AGENT_CHAT_MESSAGES, + messages: this._rowsToUI(rows) +}); + +// Seed task-panel state for the freshly connected client. Uses the +// fan-out helper rather than `send` to one connection because a second +// tab opening on the same thread should re-trigger the broadcast to +// keep siblings in sync (idempotent — frame is a full snapshot). +await broadcastTasksUpdated(this, threadId); +``` + +- [ ] **Step 2: Wire `manage_tasks` execute** + +Open `apps/kernel/src/tools/tasks.ts`. Find the existing execute() function. After the `findMany` that builds `tasks`, just before `return result;` (around line 141-143): + +Add the import at the top of the file. Find the existing imports: + +```ts +import { asc, eq, schema, ulid } from "@agent-os/models"; +import { z } from "zod"; + +import { broadcastTasksUpdated } from "../broadcasts"; +import { wrappedTool, type PerTurnContext } from "./wrapped-tool"; +``` + +Then in execute, right before the existing `return result;`: + +```ts +const result: { tasks: TaskJSON[] } = { + tasks: tasks.map((t) => ({ + /* existing mapping unchanged */ + })) +}; + +// Push the same list to the panel. Re-queries the DB inside the helper +// rather than re-using `tasks` above — keeps formatting (Date → ISO) in +// one place. One extra indexed read against a tiny table; not hot. +await broadcastTasksUpdated(ctx.kernel, ctx.threadId); + +return result; +``` + +- [ ] **Step 3: Wire `spawn_sub_agent` task.result write** + +Open `apps/kernel/src/tools/spawn-sub-agent.ts`. Find the `if (args.task_id !== undefined)` block in execute step 10 — it does `ctx.db.update(schema.task).set({ result: finalText }).where(...)`. + +Add the import at the top of the file (alongside the existing imports): + +```ts +import { broadcastTasksUpdated } from "../broadcasts"; +``` + +Then extend the conditional: + +```ts +if (args.task_id !== undefined) { + await ctx.db + .update(schema.task) + .set({ result: finalText }) + .where(eq(schema.task.id, args.task_id)); + + // Refresh the panel — task.result changed without going through + // manage_tasks, so the panel's cached state is now stale. + await broadcastTasksUpdated(ctx.kernel, ctx.threadId); +} +``` + +(The existing comment about "status untouched, that's the main agent's call" stays in place.) + +- [ ] **Step 4: Verify + commit** + +```bash +pnpm check +``` + +Expected: no errors. `ctx.kernel` and `ctx.threadId` are `PerTurnContext` fields (`apps/kernel/src/tools/wrapped-tool.ts`). + +```bash +git add apps/kernel/src/kernel.ts apps/kernel/src/tools/tasks.ts apps/kernel/src/tools/spawn-sub-agent.ts +git commit -m "feat(kernel): broadcast TASKS_UPDATED on three triggers + +- onConnect: seed panel state after CF_AGENT_CHAT_MESSAGES send +- manage_tasks execute: after DB mutations, before returning +- spawn_sub_agent: after task.result write (state change that + doesn't go through manage_tasks) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: TUI — store slice + frame handler + +**Files:** +- Modify: `apps/cli/src/store/index.ts` +- Modify: `apps/cli/src/protocol/handle-frame.ts` + +The store slice and the dispatch case ship together because the dispatcher uses a `Pick` type that must list `setTasks` to be callable. + +- [ ] **Step 1: Add `tasks` slice + `setTasks` action to the store** + +Open `apps/cli/src/store/index.ts`. Add `TaskRow` to the existing protocol imports: + +```ts +import { + MERGE_CONFLICT_RESOLUTION, + type Attachment, + type MergeConflictFrame, + type MergeConflictResolutionFrame, + type RunStatus, + type TaskRow +} from "@agent-os/protocol"; +``` + +Find the store's state interface (the type passed to `create<...>`). Add a `tasks` field next to the other state fields: + +```ts +tasks: TaskRow[]; +``` + +Add the action `setTasks` next to other setters in the actions interface: + +```ts +setTasks: (tasks: TaskRow[]) => void; +``` + +In the store creator (the `create((set, get) => ({ ... }))` block), add the initial value and action implementation. Initial value alongside other state defaults: + +```ts +tasks: [], +``` + +Action implementation alongside other actions: + +```ts +setTasks: (tasks) => set({ tasks }), +``` + +(If the file separates state defaults and action definitions into distinct objects, follow that file's pattern — both go into the single returned object passed to `create`.) + +- [ ] **Step 2: Extend `ProtocolActions` Pick + add dispatch case** + +Open `apps/cli/src/protocol/handle-frame.ts`. Find the `ProtocolActions` type (lines ~36-46). Add `setTasks` to the `Pick`: + +```ts +export type ProtocolActions = Pick< + Store, + | "setMessages" + | "setRun" + | "setPendingRequestId" + | "setMessage" + | "setNotice" + | "setIsCompacting" + | "setPendingConflict" + | "setTasks" +> & { + applyStreamChunk: (chunk: { type: string; [k: string]: unknown }) => void; + send: (raw: string) => void; + switchThread: (threadId: string) => void; +}; +``` + +Add `TASKS_UPDATED` and `TasksUpdatedFrame` to the `@agent-os/protocol` imports at the top (currently includes `COMPACT_REJECTED`, etc.): + +```ts +import { + COMPACT_REJECTED, + COMPACTION_STATUS, + MERGE_CONFLICT, + RUN_STATUS_CHANGED, + TASKS_UPDATED, + THREAD_COMPACTED, + THREAD_FROZEN, + USER_MESSAGE_REJECTED, + type CompactRejectedFrame, + type CompactionStatusFrame, + type MergeConflictFrame, + type RunStatusChangedFrame, + type TasksUpdatedFrame, + type ThreadCompactedFrame, + type ThreadFrozenFrame, + type UserMessageRejectedFrame +} from "@agent-os/protocol"; +``` + +Find the dispatch `switch (frame.type)` block. Add a new case alongside the other custom-frame cases (e.g. near `case THREAD_COMPACTED`): + +```ts +case TASKS_UPDATED: { + const f = frame as TasksUpdatedFrame; + actions.setTasks(f.tasks); + return; +} +``` + +- [ ] **Step 3: Verify + commit** + +```bash +pnpm check +``` + +Expected: no errors. The store's `tasks` slice is now reachable through `useStore((s) => s.tasks)`. The dispatcher routes the frame. + +```bash +git add apps/cli/src/store/index.ts apps/cli/src/protocol/handle-frame.ts +git commit -m "feat(cli): tasks store slice + TASKS_UPDATED dispatch + +Adds tasks: TaskRow[] state + setTasks action. handle-frame.ts +routes TASKS_UPDATED to setTasks. No UI consumer yet — TaskPanel +lands in the next task. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: TUI — `TaskPanel` component + mount in `app.tsx` + +**Files:** +- Create: `apps/cli/src/components/TaskPanel.tsx` +- Modify: `apps/cli/src/app.tsx` + +- [ ] **Step 1: Create `TaskPanel.tsx`** + +Create `apps/cli/src/components/TaskPanel.tsx` with this content: + +```tsx +import { memo } from "react"; +import { Box, Text } from "ink"; + +import type { TaskRow } from "@agent-os/protocol"; + +import { useStore } from "../store"; + +// Status-to-glyph mapping. Keep in sync with packages/models task.status enum. +// `?` is the defensive fallback if a future status enum value reaches the TUI +// before this map is updated. +const STATUS_GLYPH: Record< + TaskRow["status"], + { icon: string; color: string } +> = { + pending: { icon: "☐", color: "gray" }, + in_progress: { icon: "▶", color: "yellow" }, + complete: { icon: "✓", color: "green" }, + failed: { icon: "✗", color: "red" }, + cancelled: { icon: "⊘", color: "gray" } +}; + +function TaskPanelImpl() { + // Selector subscribes only this component to `tasks` — App stays silent on + // task changes. Matches the LiveRow pattern (per the feedback_ink_single_static + // memory: nested subscriptions prevent the live tree from re-emitting via + // Static and dropping duplicates into scrollback). + const tasks = useStore((s) => s.tasks); + + if (tasks.length === 0) return null; + + return ( + + Tasks ({tasks.length}) + {tasks.map((t) => { + const g = STATUS_GLYPH[t.status] ?? { icon: "?", color: "gray" }; + return ( + + {g.icon} + {t.name} + + ); + })} + + ); +} + +export const TaskPanel = memo(TaskPanelImpl); +``` + +- [ ] **Step 2: Mount `` in `app.tsx`** + +Open `apps/cli/src/app.tsx`. Add the import alongside other component imports (the existing import block near top of file): + +```tsx +import { TaskPanel } from "./components/TaskPanel"; +``` + +Find the live `` block (currently line 459). Mount `` after the `activePanel === "connect"` block, before the `pendingConflict` conditional: + +```tsx + + + {isCompacting ? ( + + ⏳ Compacting… + + ) : null} + {activePanel === "connections" ? ( + setActivePanel(null)} + /> + ) : null} + {activePanel === "connect" ? ( + setActivePanel(null)} /> + ) : null} + + + + {pendingConflict ? ( + + ) : inApprovalMode ? ( + + ) : ( + + )} + + {/* ...hint pills row unchanged */} + +``` + +- [ ] **Step 3: Verify + commit** + +```bash +pnpm check +``` + +Expected: no errors. TaskPanel renders nothing when `tasks` is empty, so unrendered state is the default. + +```bash +git add apps/cli/src/components/TaskPanel.tsx apps/cli/src/app.tsx +git commit -m "feat(cli): TaskPanel above input + +Compact one-line-per-task list with status glyphs (☐ pending, +▶ in_progress, ✓ complete, ✗ failed, ⊘ cancelled). Auto-hides +when zero tasks. Mounted in the live region between in-flight +streaming and the input row. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: TUI — `SpawnSubAgentTool` component + registry entry + +**Files:** +- Create: `apps/cli/src/components/tools/SpawnSubAgentTool.tsx` +- Modify: `apps/cli/src/components/tools/registry.ts` + +- [ ] **Step 1: Create `SpawnSubAgentTool.tsx`** + +Create `apps/cli/src/components/tools/SpawnSubAgentTool.tsx` with this content: + +```tsx +import { memo, useEffect, useState } from "react"; +import { Box, Text } from "ink"; + +import type { ToolRendererProps } from "./registry"; + +interface SpawnSubAgentInput { + model: "opus" | "sonnet" | "haiku"; + description: string; + task_id?: string; +} + +interface SpawnSubAgentOutput { + runId: string; + finalText: string; +} + +const MODEL_COLOR: Record = { + opus: "magenta", + sonnet: "blue", + haiku: "green" +}; + +// Local component state; resets per mount. Good enough for in-flight UX — +// not used after completion. +function useElapsedSeconds(active: boolean): number { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + if (!active) return; + const start = Date.now(); + const handle = setInterval(() => { + setElapsed(Math.floor((Date.now() - start) / 1000)); + }, 1000); + return () => clearInterval(handle); + }, [active]); + return elapsed; +} + +function SpawnSubAgentToolImpl({ part, focused }: ToolRendererProps) { + const input = part.input as Partial | undefined; + const output = part.output as SpawnSubAgentOutput | undefined; + const model: SpawnSubAgentInput["model"] = input?.model ?? "opus"; + const modelColor = MODEL_COLOR[model]; + const description = input?.description ?? ""; + const taskId = input?.task_id; + + const isRunning = part.state === "input-available"; + const elapsed = useElapsedSeconds(isRunning); + + // Failure state takes precedence over `state` — errorText is set on any + // terminal-failure path (abort, throw, model rejection). + if (part.errorText) { + return ( + + + ✗ Sub-agent ( + {model}) · failed + + {part.errorText} + + ); + } + + if (part.state === "input-streaming") { + return ( + + ⏳ Sub-agent ( + {model}) … + + ); + } + + if (isRunning) { + return ( + + + 🤖 Sub-agent ( + {model}) · {elapsed}s + + {description ? "{description}" : null} + + ); + } + + // state === "output-available" + if (!output) return null; + // Approximation: ~4 chars per token. Marked with `~` in the UI. + const approxTok = Math.ceil(output.finalText.length / 4); + + return ( + + + ✓ Sub-agent ( + {model}) · ~{approxTok} tok + + {description ? "{description}" : null} + {taskId ? → task: {taskId} : null} + {focused ? ( + + {output.finalText} + + ) : null} + + ); +} + +export const SpawnSubAgentTool = memo(SpawnSubAgentToolImpl); +``` + +- [ ] **Step 2: Register the renderer** + +Open `apps/cli/src/components/tools/registry.ts`. Add the import: + +```ts +import { SpawnSubAgentTool } from "./SpawnSubAgentTool"; +``` + +Add the entry to the `renderers` map. Place alphabetically (or at the end — match existing convention): + +```ts +export const renderers: Record> = { + read_file: ReadTool, + write_file: WriteTool, + list_files: ListFilesTool, + find_files: FindFilesTool, + web_search: WebSearchTool, + web_fetch: WebFetchTool, + computer_bash: BashTool, + exec_code: ExecCodeTool, + spawn_sub_agent: SpawnSubAgentTool +}; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +pnpm check +``` + +Expected: no errors. The component reads `ToolRendererProps` from `./registry` to avoid a circular hop through `./types` — same convention as the other tool components (check `BashTool.tsx` or `ReadTool.tsx` for the existing import shape; if they import from `./types` instead, follow that — either is fine, just stay consistent with the directory's pattern). + +```bash +git add apps/cli/src/components/tools/SpawnSubAgentTool.tsx apps/cli/src/components/tools/registry.ts +git commit -m "feat(cli): SpawnSubAgentTool custom renderer + +State machine: input-streaming (⏳) → in-flight (🤖 + elapsed timer) +→ collapsed completed (✓ + ~tok) → focus-to-expand bordered box +showing finalText. Failed state takes precedence and shows the +errorText with no expand affordance. Model badge color-coded: +opus=magenta, sonnet=blue, haiku=green. + +manage_tasks deliberately stays on GenericTool — the panel +(TaskPanel) is the canonical surface; the inline tool call is +audit-trail only. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Deploy + smoke verification + +**Files:** none (this is the verification step). + +The TUI launches against a deployed kernel — local CLI must connect to a deployed worker, not a local one. Deploy from this branch, then walk the smoke checklist. + +- [ ] **Step 1: Deploy the kernel** + +From the repo root: + +```bash +cd apps/kernel && pnpm deploy +``` + +Expected: wrangler deploys to the configured worker. Note the version id and rough deploy time — useful if a smoke step needs debugging via Workers Observability later. + +- [ ] **Step 2: Launch the TUI against deployed kernel** + +From repo root, in a new terminal: + +```bash +cd apps/cli && pnpm dev +``` + +(Or whatever launch script the repo uses for the CLI. Plain `tsx` not `tsx watch` — TUIs reading stdin can't tolerate restart-on-keystroke; see the `feedback_tsx_watch_tui` memory.) + +Expected: TUI connects, shows the empty input box. + +- [ ] **Step 3: Smoke checklist** + +Work through each scenario from the spec (§9). Mark each ✅/❌ as you go. If anything fails, capture the error from Workers Observability (CF dashboard → Logs panel) and treat as a fix-and-rerun loop, not progress. + +- [ ] **3.1 Fresh thread, no tasks** — Open TUI on a new thread. Panel does NOT render. Zero vertical space above input. +- [ ] **3.2 Agent creates tasks** — Prompt a multi-step request ("plan a refactor and execute it"). On `manage_tasks` tool call, panel appears with all created tasks in `pending` state (☐ glyph, gray). +- [ ] **3.3 Status transitions** — Watch tasks move pending → in_progress (▶ yellow) → complete (✓ green). Glyphs and colors update live without scrollback duplication. +- [ ] **3.4 Resume thread** — Quit the TUI mid-thread (Ctrl+C). Restart with `--resume ` or via the `/threads` picker. Panel rehydrates with the latest state from DB. +- [ ] **3.5 Sub-agent in-flight** — Agent calls `spawn_sub_agent` (opus). Confirm `🤖 Sub-agent (opus) · s` renders with elapsed ticking. Description line under it. +- [ ] **3.6 Sub-agent completion + focus expand** — After completion, row collapses to `✓ Sub-agent (opus) · ~ tok`. Tab/scroll to it (whatever Ctrl+O selects), press `Ctrl+O` to expand the bordered finalText box. +- [ ] **3.7 Sub-agent failure** — Force a failure (use `model: "haiku"` and a long prompt — adaptive thinking error; or abort mid-run). Verify `✗ Sub-agent (model) · failed` + error message renders. No expand affordance. +- [ ] **3.8 Sub-agent with task_id refreshes panel** — Spawn a sub-agent with `task_id=`. Confirm the panel re-renders when the sub-agent completes, EVEN if `manage_tasks` wasn't called in the same turn. (Proves trigger 3 fires.) +- [ ] **3.9 Two tabs on one thread** — Open a second terminal, `--resume` the same threadId. Run a `manage_tasks` op in tab 1. Confirm tab 2's panel updates without input. +- [ ] **3.10 Empty broadcast** — Cancel all tasks (`manage_tasks` with status=cancelled per task — they still show in the panel with ⊘ glyph). To verify the empty-panel-hide path: open a fresh thread, confirm panel hidden. The "all tasks deleted" path is harder to reproduce naturally; the empty-on-fresh-thread case covers the same code path (`tasks.length === 0` → return null). + +- [ ] **Step 4: PR-readiness checks** + +After smoke passes: + +- [ ] All commits push cleanly. `git log --oneline main..HEAD` shows 6 commits (Tasks 1-6). +- [ ] No PR-letter markers leaked into code. `grep -rn "// PR-" apps/cli/src apps/kernel/src packages/protocol/src` returns nothing. +- [ ] No regressions in the existing tool renderers. Open the TUI, run any `read_file` / `web_search` / `computer_bash` invocation, confirm they still look unchanged. + +- [ ] **Step 5: Open PR** + +```bash +git push -u origin feat/tui-tasks-subagent +gh pr create --base main --title "feat(cli,kernel): TUI task panel + sub-agent renderer (PR-E)" --body "Surfaces PR-C (tasks) and PR-D (sub-agents) backend state in the Ink TUI. + +**Two surfaces:** +- TaskPanel above input — compact, hidden when empty, status glyphs +- SpawnSubAgentTool renderer — state-machine (in-flight → collapsed → focus-to-expand → failed) + +**Transport:** new TASKS_UPDATED protocol frame, push-payload. Broadcast on three triggers: onConnect, manage_tasks execute, spawn_sub_agent task.result write. + +**Spec:** \`docs/superpowers/specs/2026-05-15-tui-tasks-and-subagent-design.md\` +**Plan:** \`docs/superpowers/plans/2026-05-15-tui-tasks-and-subagent.md\` + +Smoke checklist (10 scenarios) walked through post-deploy — all pass. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Self-review (post-write) + +**Spec coverage:** + +| Spec section | Implementing task(s) | +|---|---| +| §3 architecture | Tasks 1-6 collectively (each piece) | +| §4 protocol frame | Task 1 | +| §5.1 broadcastTasksUpdated helper | Task 2 | +| §5.2 onConnect trigger | Task 3 step 1 | +| §5.3 manage_tasks trigger | Task 3 step 2 | +| §5.4 spawn_sub_agent trigger | Task 3 step 3 | +| §6.1 store slice | Task 4 step 1 | +| §6.2 frame handler | Task 4 step 2 | +| §6.3 TaskPanel | Task 5 step 1 | +| §6.4 TaskPanel mount | Task 5 step 2 | +| §6.5 SpawnSubAgentTool | Task 6 step 1 | +| §6.6 registry registration | Task 6 step 2 | +| §9 smoke scenarios | Task 7 | +| §10 house-keeping | "House-keeping rules" section at top | + +All sections covered. ✓ + +**Type consistency:** +- `TaskRow` defined Task 1, consumed Tasks 2, 4, 5. ✓ +- `broadcastTasksUpdated` signature `(kernel: Kernel, threadId: string) => Promise` consistent across Tasks 2, 3. ✓ +- `setTasks: (tasks: TaskRow[]) => void` consistent Tasks 4. ✓ +- `SpawnSubAgentInput.model` enum matches kernel's MODEL_ROUTE map (`opus | sonnet | haiku`). ✓ +- `SpawnSubAgentOutput { runId, finalText }` matches `apps/kernel/src/tools/spawn-sub-agent.ts` SpawnSubAgentResult. ✓ + +**Placeholder scan:** none. Every step has exact code, exact paths, exact commands. + +**Files-vs-tasks consistency:** the 10 files in the §File map all appear in at least one task. ✓ diff --git a/docs/superpowers/specs/2026-05-15-tui-tasks-and-subagent-design.md b/docs/superpowers/specs/2026-05-15-tui-tasks-and-subagent-design.md new file mode 100644 index 0000000..354264a --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-tui-tasks-and-subagent-design.md @@ -0,0 +1,559 @@ +# PR-E: TUI Task Panel + Sub-Agent Custom Renderer — Design + +> **Status:** approved (brainstorm 2026-05-15). +> **Scope:** TUI rendering layer for PR-C (tasks) + PR-D (sub-agents). Adds one protocol frame, one kernel helper, three broadcast triggers, two new TUI components. + +--- + +## 1. Goal + +Make the work that the agent already does on the backend (manage_tasks, spawn_sub_agent) legible in the TUI. + +Two surfaces ship: + +1. **Task panel** — a compact, always-all-tasks list rendered above the input box, like Claude Code's. Hidden when the thread has zero tasks. Updates live when the agent creates / updates tasks OR a sub-agent writes a task's `result`. +2. **`spawn_sub_agent` custom renderer** — replaces the GenericTool fallback. Shows a clean state machine: in-flight indicator → collapsed summary with focus-to-expand → failed/aborted state with the error message. + +The frame transport is **push-payload from kernel** (Option A from the brainstorm), not refetch-from-API. A new `TASKS_UPDATED` frame carries the full task array, broadcast on three triggers: client connects, `manage_tasks` completes, `spawn_sub_agent` writes `task.result`. The DB is the source of truth; the frame is delivery. No new HTTP route. + +The `manage_tasks` tool call itself stays on the GenericTool fallback — the panel is the canonical surface; the inline tool call is the audit-trail confirmation only. + +## 2. Non-goals + +These are intentionally deferred to a future PR: + +- **Sub-agent streaming visibility.** The in-flight state shows a single line ("Sub-agent (opus) · 14s · description") — no token-by-token streaming of the sub-agent's body. User explicitly said "fine for now" on 2026-05-15. +- **Compaction-event scrollback indicator.** `turn-compaction.ts` runs silently today; surfacing it is a separate, smaller win. +- **Cost / token meter.** Out of scope. +- **Sub-agent transcript retro view.** No "drill into the sub-agent's nested run history" view — the main agent's summary + finalText is enough surface. +- **Interactive task editing from the TUI.** Panel is read-only display. The agent owns task state via `manage_tasks`; user cannot manually mark complete or delete from the TUI. +- **Panel height cap / scroll.** YAGNI — tasks per thread typically <10. If it becomes a problem, add later. +- **Custom renderer for `manage_tasks`.** Stays on GenericTool fallback. + +## 3. Architecture + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Kernel (DO) │ +│ │ +│ manage_tasks tool ────────────┐ │ +│ spawn_sub_agent (sets result)─┤ │ +│ kernel.onConnect ─────────────┴──→ broadcastTasksUpdated() │ +│ │ │ +│ ▼ │ +│ query schema.task │ +│ (eq threadId, asc createdAt)│ +│ │ │ +│ ▼ │ +│ broadcastToThread( │ +│ threadId, │ +│ { type: TASKS_UPDATED, │ +│ threadId, │ +│ tasks: TaskJSON[] }) │ +└──────────────────────────────────────────────────────────│─────┘ + │ WS +┌──────────────────────────────────────────────────────────▼─────┐ +│ TUI (apps/cli) │ +│ │ +│ handle-frame.ts dispatches TASKS_UPDATED → store.setTasks() │ +│ │ │ +│ ▼ │ +│ │ +│ history │ +│ │ +│ in-flight streaming │ +│ NEW — hidden if tasks empty │ +│ | ... │ +│ │ +│ │ +│ Tool registry adds: │ +│ spawn_sub_agent: SpawnSubAgentTool NEW │ +│ (manage_tasks stays on GenericTool fallback) │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Why push-payload over refetch:** DB is the source of truth either way. Push saves one round trip per update, avoids the "is there a new HTTP route?" question entirely, and matches the existing protocol pattern (`RUN_STATUS_CHANGED`, `THREAD_COMPACTED`, etc.) — every new state event in this codebase goes through the broadcast layer, not through new REST routes. + +**Why broadcast on `spawn_sub_agent` task.result write too:** Without it, the panel would show stale `task.result` until the next `manage_tasks` call. Sub-agents write `task.result` directly without going through `manage_tasks`, so the panel needs its own signal. + +**Why DB queue not message-history derived:** Reading "latest manage_tasks output from message parts" works for `manage_tasks`-driven state, but breaks for `spawn_sub_agent`-driven `result` updates (which don't appear in the message stream). DB-query-and-broadcast is the only path that's correct in both cases. + +## 4. Protocol — `TASKS_UPDATED` frame + +### 4.1 Frame definition + +Added to `packages/protocol/src/index.ts`: + +```ts +export const TASKS_UPDATED = "agent_os.tasks.updated" as const; + +// One task as wire-rendered. Matches TaskJSON from kernel/src/tools/tasks.ts +// (re-defined here so TUI doesn't import from kernel package). +export interface TaskRow { + id: string; + name: string; + description: string; + status: "pending" | "in_progress" | "complete" | "failed" | "cancelled"; + result: string | null; + createdAt: string; // ISO 8601 + updatedAt: string; +} + +export type TasksUpdatedFrame = { + type: typeof TASKS_UPDATED; + threadId: string; + tasks: TaskRow[]; // full list, sorted by createdAt ASC +}; + +// Add TasksUpdatedFrame to the KernelToClient union. +export type KernelToClient = + | { type: "pong" } + | { type: "error"; message: string } + | RunStatusChangedFrame + | MergeConflictFrame + | CompactionStatusFrame + | ThreadCompactedFrame + | ThreadFrozenFrame + | CompactRejectedFrame + | UserMessageRejectedFrame + | TasksUpdatedFrame; // ← new +``` + +### 4.2 Frame semantics + +- **Full snapshot, not delta.** Every `TASKS_UPDATED` carries the complete task array for `threadId`, sorted by `createdAt ASC`. No add/update/delete actions to interpret. Receiver replaces state wholesale. +- **Idempotent.** Re-sending the same frame is safe. Race-free on the WS because frames are ordered. +- **Per-thread.** Broadcast goes via `broadcastToThread`, so clients only receive frames for the thread they're connected to. The `threadId` field on the frame is for the receiver to guard against stale receives (e.g., if a stale connection landed on a new thread). +- **Empty array is valid.** Means "thread has no tasks" — panel hides. + +## 5. Kernel changes + +### 5.1 `broadcastTasksUpdated` helper + +Added to `apps/kernel/src/broadcasts.ts` alongside `broadcastRunStatus`. Both follow the same "query DB, build frame, broadcast" shape: + +```ts +import { asc, eq, schema } from "@agent-os/models"; +import { TASKS_UPDATED } from "@agent-os/protocol"; +// ... existing imports + +// Outbound union gains TasksUpdatedFrame. + +// Query the thread's current task list and fan it out to all connections +// watching the thread. Called from three sites: +// - kernel.onConnect (initial state seed) +// - tools/tasks.ts execute() (after manage_tasks mutations) +// - tools/spawn-sub-agent.ts (after writing task.result) +// +// Full-snapshot rather than delta: simpler client, ordered-WS makes it +// safe, and the row count per thread is always tiny. +export async function broadcastTasksUpdated( + kernel: Kernel, + threadId: string +): Promise { + const rows = await kernel.db.query.task.findMany({ + where: eq(schema.task.threadId, threadId), + orderBy: [asc(schema.task.createdAt)] + }); + const tasks: TaskRow[] = rows.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + status: t.status, + result: t.result, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString() + })); + broadcastToThread(kernel, threadId, { + type: TASKS_UPDATED, + threadId, + tasks + }); +} +``` + +Note: `kernel.db` is the DO's drizzle handle (sibling of `kernel.getConnections`). Caller doesn't pass `ctx.db` because the broadcast can fire from contexts that don't have a `PerTurnContext` (notably `onConnect`). + +### 5.2 Trigger 1 — `onConnect` + +In `apps/kernel/src/kernel.ts`'s `onConnect` (around the existing `CF_AGENT_CHAT_MESSAGES` send near line 583), add a follow-up send after the message history: + +```ts +// existing: +send(connection, { + type: MessageType.CF_AGENT_CHAT_MESSAGES, + messages: this._rowsToUI(rows) +}); + +// new — seed task panel state. Fire-and-forget (matches the existing +// resumable-stream check below it). +await broadcastTasksUpdated(this, threadId); +``` + +Why `broadcastTasksUpdated` (fan-out) instead of `send` (single connection): a second tab opening on the same thread should re-trigger broadcast so it gets the latest state too. The fan-out hits the new connection and any siblings idempotently — net cost is one extra `JSON.stringify` per existing connection, fine for the typical 1-2 connection case. + +### 5.3 Trigger 2 — after `manage_tasks` execute + +In `apps/kernel/src/tools/tasks.ts`, after the existing `findMany` that builds the return value (line 127-141), add the broadcast before returning: + +```ts +const result: { tasks: TaskJSON[] } = { /* ... existing mapping */ }; + +// Push the full list to all connections on this thread. Mirror of the +// data already in `result`, but goes to the panel instead of the +// assistant's tool-output stream. +await broadcastTasksUpdated(ctx.kernel, ctx.threadId); + +return result; +``` + +Subtle: the broadcast re-queries the DB rather than re-using the local `tasks` variable. That's intentional — keeps `broadcastTasksUpdated` as the single source of formatting truth (one place that maps `Date → ISO string`, etc.). Cost is one extra SQLite query against a tiny indexed table. + +### 5.4 Trigger 3 — after `spawn_sub_agent` writes `task.result` + +In `apps/kernel/src/tools/spawn-sub-agent.ts`, after the conditional `update(schema.task).set({ result: finalText })` call (step 10 of execute), add: + +```ts +if (args.task_id !== undefined) { + await ctx.db + .update(schema.task) + .set({ result: finalText }) + .where(eq(schema.task.id, args.task_id)); + + // Panel needs a refresh — task.result changed without going through + // manage_tasks, so the panel's last cached state is stale. + await broadcastTasksUpdated(ctx.kernel, ctx.threadId); +} +``` + +(The existing comment about "status untouched, that's the main agent's call" stays.) + +### 5.5 What does NOT broadcast + +- **Task created/updated by a sub-agent's own `manage_tasks` call.** Sub-agents are filtered out of message history (PR-D's transparency model), but their `manage_tasks` calls still write to `schema.task` with the main thread's `threadId`. The `manage_tasks` execute path runs `broadcastTasksUpdated` unconditionally, so this case is already covered — the panel reflects sub-agent task changes the same way it reflects main-agent ones. +- **Compaction fork.** When a thread is compacted into a new threadId, tasks don't transfer (PR-C decision). The new thread's `onConnect` will broadcast its empty task list, hiding the panel. Documented as expected behavior, not a bug. + +## 6. TUI changes + +### 6.1 Store slice + +`apps/cli/src/store/index.ts` gains: + +```ts +import type { TaskRow } from "@agent-os/protocol"; + +interface State { + // ... existing + tasks: TaskRow[]; // current thread's task list (sorted asc) +} + +interface Actions { + // ... existing + setTasks: (tasks: TaskRow[]) => void; +} + +// in the store creator: +tasks: [], +setTasks: (tasks) => set({ tasks }), +``` + +**Thread switch behavior:** When the user resumes a different thread, the WS is torn down and a new `onConnect` fires on the new thread. Kernel sends fresh `TASKS_UPDATED` for the new thread (possibly empty). The store's `tasks` is replaced wholesale. No multi-thread state to manage. + +### 6.2 Frame handler + +In `apps/cli/src/protocol/handle-frame.ts`, add a case: + +```ts +case TASKS_UPDATED: { + store.setTasks(msg.tasks); + return; +} +``` + +(Following the existing pattern of dispatch-only, no side effects.) + +### 6.3 `TaskPanel` component + +New file: `apps/cli/src/components/TaskPanel.tsx`. + +```tsx +import { memo } from "react"; +import { Box, Text } from "ink"; +import { useStore } from "../store"; + +const STATUS_GLYPH: Record = { + pending: { icon: "☐", color: "gray" }, + in_progress: { icon: "▶", color: "yellow" }, + complete: { icon: "✓", color: "green" }, + failed: { icon: "✗", color: "red" }, + cancelled: { icon: "⊘", color: "gray" } +}; + +function TaskPanelImpl() { + // Subscribe to just the tasks slice — App doesn't re-render on tasks change. + const tasks = useStore((s) => s.tasks); + + // Auto-hide when empty. + if (tasks.length === 0) return null; + + return ( + + Tasks ({tasks.length}) + {tasks.map((t) => { + const g = STATUS_GLYPH[t.status]; + return ( + + {g.icon} + {t.name} + + ); + })} + + ); +} + +export const TaskPanel = memo(TaskPanelImpl); +``` + +Notes: +- `memo` because nothing in `tasks` reference-changes unless the array itself does, and `setTasks` replaces wholesale on every frame. +- Uses a dedicated selector — the rest of the App component doesn't subscribe to `tasks`, so App stays silent during streaming. +- Per the `feedback_ink_single_static` memory: this lives in the live region (Fragment-as-root with Static + live Box as siblings), NOT inside Static. Re-emitting on update is safe because it's not Static. + +### 6.4 `TaskPanel` mount + +In `apps/cli/src/app.tsx`, add `` to the live Box between `` and the input/conflict/approval row. Concrete placement (around line 459-507): + +```tsx + + + {isCompacting && } + {activePanel === "connections" && } + {activePanel === "connect" && } + + {/* ← NEW: auto-hides when no tasks */} + + {pendingConflict ? ( + + ) : pendingApprovals.length > 0 ? ( + + ) : ( + + )} + + + + +``` + +The panel renders right above the input row when present, matching Claude Code. When tasks is empty the component returns null and contributes zero vertical space. + +### 6.5 `SpawnSubAgentTool` component + +New file: `apps/cli/src/components/tools/SpawnSubAgentTool.tsx`. + +State machine driven by `part.state`: + +| `part.state` | Render shape | +|-----------------------|--------------| +| `input-streaming` | `⏳ Sub-agent (opus) …` (args still streaming in) | +| `input-available` | `🤖 Sub-agent (opus) · 14s` + description line. Elapsed time ticks while in this state. | +| `output-available` | Header: `✓ Sub-agent (opus) · 32s · ~412 tok` + description + (optional) task linkage line. When `focused` (Ctrl+O), expand to show full `finalText` in a bordered box. Collapsed by default. | +| error / errorText set | `✗ Sub-agent (opus) · failed (4s)` + `errorText` body. No expand needed (error already shown). | + +Model badge colors: + +| `input.model` | color | +|---|---| +| `opus` | `magenta` | +| `sonnet` | `blue` | +| `haiku` | `green` | + +Component sketch (illustrative; exact Ink layout up to implementer): + +```tsx +import { memo, useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import type { ToolRendererProps } from "./types"; + +interface SpawnSubAgentInput { + model: "opus" | "sonnet" | "haiku"; + prompt: string; + task_id?: string; +} +interface SpawnSubAgentOutput { + runId: string; + finalText: string; +} + +const MODEL_COLOR = { + opus: "magenta", + sonnet: "blue", + haiku: "green" +} as const; + +function SpawnSubAgentToolImpl({ part, focused }: ToolRendererProps) { + const input = part.input as Partial | undefined; + const output = part.output as SpawnSubAgentOutput | undefined; + const model = input?.model ?? "opus"; + const prompt = input?.prompt ?? ""; + const taskId = input?.task_id; + + // Elapsed-time ticker only runs while in-flight. + const [elapsed, setElapsed] = useState(0); + const isRunning = part.state === "input-available"; + useEffect(() => { + if (!isRunning) return; + const start = Date.now(); + const handle = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000); + return () => clearInterval(handle); + }, [isRunning]); + + if (part.errorText) { + return ( + + ✗ Sub-agent ({model}) · failed + {part.errorText} + + ); + } + + if (part.state === "input-streaming") { + return ( + + ⏳ Sub-agent ({model}) … + + ); + } + + if (isRunning) { + return ( + + + 🤖 Sub-agent ({model}) · {elapsed}s + + {prompt && "{prompt}"} + + ); + } + + // output-available + if (!output) return null; + const approxTok = Math.ceil(output.finalText.length / 4); + return ( + + + ✓ Sub-agent ({model}) · ~{approxTok} tok + + {prompt && "{prompt}"} + {taskId && → task: {taskId}} + {focused && ( + + {output.finalText} + + )} + + ); +} + +export const SpawnSubAgentTool = memo(SpawnSubAgentToolImpl); +``` + +Notes: +- Elapsed time is local component state (ticker), not from the kernel. Resets on every re-mount. Good enough for the in-flight indicator; not used post-completion. +- The token count is `finalText.length / 4` — a rough char-to-token approximation. Don't oversell as exact. +- Task linkage line shows `task_id` only. If product later wants the task's `name` shown instead, a one-line `useStore((s) => s.tasks.find(t => t.id === taskId)?.name)` lookup adds it without restructuring. +- No keyboard interaction added — `focused` comes from the existing scrollback focus state used by other tool renderers (`ReadTool`, `WebSearchTool` use it for the same purpose). + +### 6.6 Registry registration + +In `apps/cli/src/components/tools/registry.ts`, add one entry: + +```ts +import { SpawnSubAgentTool } from "./SpawnSubAgentTool"; + +const renderers: Record> = { + read_file: ReadTool, + // ... existing + exec_code: ExecCodeTool, + spawn_sub_agent: SpawnSubAgentTool, // ← new +}; +``` + +`manage_tasks` is intentionally NOT registered — falls through to `GenericTool`. + +## 7. Data flow walkthrough + +**Scenario:** user resumes a thread that already has 3 tasks; agent runs `manage_tasks` to mark one complete; agent then `spawn_sub_agent`s with `task_id` on another. + +1. TUI opens WS to `/?threadId=THR_1`. +2. Kernel `onConnect` sends `CF_AGENT_CHAT_MESSAGES` (history) then `broadcastTasksUpdated(THR_1)`. +3. `broadcastTasksUpdated` queries `schema.task` where `threadId=THR_1`, builds `TasksUpdatedFrame` with 3 rows, fans out. +4. TUI `handle-frame.ts` sees `TASKS_UPDATED` → `store.setTasks([...3 rows])`. `` selector fires, panel renders. +5. User types a message → kernel runs the turn → agent emits `manage_tasks` tool call with `update` op for task #2 → tool execute runs, mutates DB, calls `broadcastTasksUpdated(THR_1)`. +6. TUI receives `TASKS_UPDATED` (now 3 rows, one with `status: "complete"`), `setTasks` replaces the array, `` re-renders with the new glyph. +7. Same turn, agent emits `spawn_sub_agent` tool call → `SpawnSubAgentTool` renders `🤖 Sub-agent (opus) · 0s` in the message stream. Elapsed ticks. +8. Sub-agent completes, writes `task.result` for task #3 → `broadcastTasksUpdated(THR_1)` fires again → panel re-renders (no visible change since the panel doesn't show `result`, but state in store is current for any future code that wants it). +9. Tool output lands on the message stream → `SpawnSubAgentTool` re-renders with completed state, finalText collapsed. User presses `Ctrl+O` to focus and expand. + +## 8. Error handling + +- **`broadcastTasksUpdated` throws.** Should not propagate to user-visible errors. The data write (manage_tasks mutation, sub_agent result write) already succeeded; the broadcast failing is a "panel goes stale" issue. Wrap in try/catch with a `console.error` and continue. +- **`TASKS_UPDATED` frame with wrong `threadId`.** The frame carries `threadId`; TUI guards by checking against the currently-connected thread. If mismatched (shouldn't happen given per-thread broadcast filter), ignore. Log to console. +- **TaskRow with unknown status.** `STATUS_GLYPH` lookup fails. Render with a fallback glyph `?` and dim color rather than throwing. Defensive — protects against a future schema enum addition that the TUI doesn't know about yet. +- **`spawn_sub_agent` output shape malformed.** `output as SpawnSubAgentOutput` cast can be wrong if the kernel changes the shape. Component checks `!output` early and renders nothing — better than crashing the message view. + +## 9. Smoke test scenarios + +To verify post-deploy. Use the existing TUI (`apps/cli`) against a deployed kernel. + +1. **Fresh thread, no tasks.** Open TUI, start a thread. Panel should NOT render (zero vertical space above input). +2. **Agent creates tasks.** Prompt the agent with something multi-step ("plan + execute a refactor"). On `manage_tasks` tool call, panel appears with all created tasks in `pending` state. +3. **Task status transitions.** Watch through a turn that creates → marks in_progress → marks complete. Glyphs and colors update live. +4. **Resume thread.** Kill TUI mid-thread, restart with `--resume ` (or pick from `/threads`). Panel rehydrates with the latest state from DB. +5. **Sub-agent in-flight indicator.** Agent calls `spawn_sub_agent`. Confirm the elapsed timer ticks. Model badge color matches the picked model. +6. **Sub-agent completion + focus expand.** After sub-agent returns, the row collapses to one line. `Ctrl+O` (or whatever the existing focus key is) expands to show `finalText` in a bordered box. +7. **Sub-agent failure.** Force a failure (e.g., aborted run or model-tier error). Verify `✗ Sub-agent (model) · failed` + error message renders. +8. **Sub-agent with task_id refreshes panel.** Spawn a sub-agent with `task_id=`. When the sub-agent completes, the panel re-renders even though no `manage_tasks` was called (proves the third broadcast trigger fires). +9. **Two TUI tabs on one thread.** Open two terminals to the same thread. Run a `manage_tasks` op in one. Confirm both panels update. +10. **Empty broadcast.** Manually delete all tasks via SQL (or `manage_tasks` with status=cancelled and let cleanup if any). Confirm `TASKS_UPDATED` with `tasks: []` hides the panel. + +## 10. House-keeping rules + +- **No PR-letter markers in code.** Don't add `// PR-E` or `// NEW (PR-E)` comments. Same rule as PR-D. Comments describe what the code does or why, not which slice it landed in. +- **Strip any leftover PR-A/B/C/D markers found while editing touched files** (Task 0 of PR-D's pattern). Touched files in PR-E include `kernel.ts`, `broadcasts.ts`, `tools/tasks.ts`, `tools/spawn-sub-agent.ts` — quick scan for `// PR-` prefixes, drop them if found. +- **Lint with oxlint + oxfmt** before commit. Matches `feedback_lint_format` memory. +- **No new tests in the plan.** Smoke checklist (§9) covers verification. Matches `feedback_minimal_tests_in_plans`. +- **Use opus for subagents** during implementation. Matches `feedback_subagent_opus_default`. + +## 11. Open questions + +None remaining at spec-write time. All resolved during the brainstorm (2026-05-15): + +| Question | Resolution | +|---|---| +| Transport: push-payload vs refetch vs stream-derived? | Push-payload (§4) | +| `manage_tasks` custom renderer? | Keep GenericTool fallback (§6.6) | +| Panel position in live region? | Below LiveRow, above input (§6.4) | +| Panel height cap for many tasks? | No cap, render all (§2) | +| Sub-agent streaming visibility? | Out of scope, deferred (§2) | +| Sub-agent in-flight indicator format? | Elapsed timer + model badge + description (§6.5) | +| Sub-agent completed-state expansion? | Focus-to-expand convention (§6.5) | +| Sub-agent failed-state rendering? | Header + errorText body, no expand (§6.5) | + +## 12. Decisions table + +| Decision | Choice | Why | +|---|---|---| +| Transport | TASKS_UPDATED frame, full payload | DB is source of truth, frame is delivery; matches existing protocol patterns | +| Broadcast triggers | onConnect, manage_tasks execute, sub_agent task.result write | Three sites where DB task state changes | +| Snapshot vs delta | Full snapshot | Simpler client, tiny payload, race-free | +| Panel position | Below LiveRow, above input | Matches Claude Code | +| Panel visibility | Auto-hide when empty | No clutter on fresh threads | +| Panel content | name + status glyph only | Description/result hidden — agent owns those views | +| Sort order | createdAt ASC | Matches `manage_tasks` tool's own ordering | +| Sub-agent renderer | Custom (`SpawnSubAgentTool`) | User explicitly requested | +| manage_tasks renderer | GenericTool fallback | Panel is canonical surface | +| Focus interaction | Focus-to-expand on completed state | Matches `ReadTool`, `WebSearchTool` | +| Model badge color | opus=magenta, sonnet=blue, haiku=green | Visual scan at glance | +| Token count | `finalText.length / 4` | Rough approximation, marked with `~` | diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 37e9375..ca03b63 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -50,6 +50,14 @@ export const THREAD_FROZEN = "agent_os.thread.frozen" as const; export const COMPACT_REJECTED = "agent_os.compact.rejected" as const; export const USER_MESSAGE_REJECTED = "agent_os.user_message.rejected" as const; +// Tasks panel state. Kernel pushes the full task list for the connected +// thread on three triggers: onConnect (initial seed), manage_tasks execute +// (state change), spawn_sub_agent (task.result write). DB is the source of +// truth; the frame is delivery. Snapshot rather than delta — receiver +// replaces state wholesale on each frame. Empty `tasks` is valid and means +// the panel should hide. +export const TASKS_UPDATED = "agent_os.tasks.updated" as const; + // Pause is the library's CHAT_REQUEST_CANCEL { id: requestId }. Server // generates requestIds for all turn types (user-initiated, resume, retry, // autoContinue, fiber recovery) and emits them in CF_AGENT_USE_CHAT_RESPONSE @@ -155,6 +163,27 @@ export type UserMessageRejectedFrame = { cap: number; }; +// One task as wire-rendered. Mirrors TaskJSON from kernel/src/tools/tasks.ts +// but re-defined here so the TUI doesn't depend on the kernel package. +export interface TaskRow { + id: string; + name: string; + description: string; + status: "pending" | "in_progress" | "complete" | "failed" | "cancelled"; + result: string | null; + /** ISO 8601 */ + createdAt: string; + /** ISO 8601 */ + updatedAt: string; +} + +export type TasksUpdatedFrame = { + type: typeof TASKS_UPDATED; + threadId: string; + /** Full list, sorted by createdAt ASC. */ + tasks: TaskRow[]; +}; + export type ClientToKernel = | { type: "ping" } | RunResumeFrame @@ -171,7 +200,8 @@ export type KernelToClient = | ThreadCompactedFrame | ThreadFrozenFrame | CompactRejectedFrame - | UserMessageRejectedFrame; + | UserMessageRejectedFrame + | TasksUpdatedFrame; /** * One file the TUI uploaded ahead of a CHAT_REQUEST. The kernel persists the