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