@taicho-ai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/App.tsx ADDED
@@ -0,0 +1,1404 @@
1
+ import { useState, useRef, useEffect, useMemo } from "react";
2
+ import { Box, Text, Static, useInput, useApp, useStdout, type Key } from "ink";
3
+ import { Spinner } from "@inkjs/ui";
4
+ import { ChatInput } from "./ChatInput";
5
+ import { pushHistory, loadHistory, appendHistory } from "./input-history";
6
+ import type { Database } from "bun:sqlite";
7
+ import { ProposalCard, type CardField, type CardKeyHandler } from "./ProposalCard";
8
+ import { QuestionCard } from "./QuestionCard";
9
+ import { StatusBar } from "./StatusBar";
10
+ import { SquadPanes, resolveLayout, type PaneEntry, type PaneFeedMap } from "./SquadPanes";
11
+ import { AgentBlock, useBlockSettle, useBlockTicker, tailLines, type AgentBlockData } from "./AgentBlock";
12
+ import { OperationView } from "./OperationView";
13
+ import { ArtifactBrowser, initialBrowserUi, type BrowserUiState } from "./ArtifactBrowser";
14
+ import { OrgBrowser, initialOrgUi, type OrgUiState, type OrgActions } from "./OrgBrowser";
15
+ import { WorkflowBrowser, initialWorkflowUi, type WorkflowUiState } from "./WorkflowBrowser";
16
+ import type { OrgScope } from "./org-browser-model";
17
+ import { latestRunFallback } from "./browser-model";
18
+ import { parseInput } from "./input";
19
+ import { BANNER } from "./banner";
20
+ import { statusReducer, statusList, type StatusMap, type AgentStatus } from "@taicho-ai/framework/core/agent-status";
21
+ import { gatherConversationArtifacts } from "@taicho-ai/framework/core/conversation-artifacts";
22
+ import { makeDeps, executeRun, type Model, type ApprovalRequest, type ApprovalDecision } from "@taicho-ai/framework/core/run";
23
+ import { loadAgent, loadIndex, reindex, createAgent, setAgentTeams, deleteAgent, LIBRARIAN_ID, type RegistryRow } from "@taicho-ai/framework/store/roster";
24
+ import { listTeams, createTeamWithMembers, deleteTeam, setTeamMembers } from "@taicho-ai/framework/store/teams";
25
+ import { scaffoldWorkflow } from "@taicho-ai/framework/store/workflows";
26
+ import { PlanPanel } from "./PlanPanel";
27
+ import type { PlanState } from "@taicho-ai/contracts/plan";
28
+ import { currentPlanId, foldPlan, settlePlanItemForTask } from "@taicho-ai/framework/store/plans";
29
+ import { listTraces, readTrace } from "@taicho-ai/framework/store/trace";
30
+ import { clearConversation } from "@taicho-ai/framework/store/conversation";
31
+ import { listPolicies, deletePolicy, approvePolicy } from "@taicho-ai/framework/store/policy";
32
+ import { updateTaskFromTrace, createBackgroundTask, setTaskFields, cancelTaskState, listTaskIndex, readTaskState, mkTaskId, TERMINAL_TASK_STATUS } from "@taicho-ai/framework/store/task-state";
33
+ import { TaskScheduler } from "@taicho-ai/framework/core/tasks";
34
+ import { SchedulerRunner, parseScheduleCommand, describeTrigger, formatScheduleLine } from "@taicho-ai/framework/core/scheduler";
35
+ import { runHeadless, scheduleFireOptions } from "@taicho-ai/framework/core/headless";
36
+ import { listSchedules, createSchedule, removeSchedule, readSchedule, updateSchedule } from "@taicho-ai/framework/store/schedules";
37
+ import type { Schedule } from "@taicho-ai/contracts/schedule";
38
+ import { statSync } from "node:fs";
39
+ import type { RunResult, TaskAwaitResult, RunDeps } from "@taicho-ai/framework/core/run";
40
+ import type { ModelMessage } from "ai";
41
+ import type { AuthSource, TaichoConfig } from "@taicho-ai/framework/store/config";
42
+ import { isStdioServer } from "@taicho-ai/framework/store/config";
43
+ import type { SpendLedger } from "@taicho-ai/framework/store/spend-ledger";
44
+ import type { Telemetry } from "@taicho-ai/telemetry";
45
+ import { formatAuthStatus, noCredentialLines, authExpiredMessage } from "@taicho-ai/framework/core/auth/status";
46
+ import { runSlash as runSlashPure, type Line, type SlashCommand, suggestCommands, cycleIndex } from "./slash";
47
+ import { renderMarkdown } from "./markdown";
48
+ import { classifySystemLine, userBarLines, annotateSpacing, type SpacedLine } from "./transcript-style";
49
+ import { threadToLines } from "./transcript-hydrate";
50
+ import { splitCompletedBlocks } from "./markdown-stream";
51
+ import { draftPolicy, persistApprovedPolicy } from "@taicho-ai/framework/coaching/teach";
52
+ import { mergeDraft } from "@taicho-ai/framework/core/draft";
53
+ import type { McpManager } from "@taicho-ai/framework/core/mcp/manager";
54
+ import { addMcpServer, removeMcpServer } from "@taicho-ai/framework/store/mcp-store";
55
+ import { parseMcpCommand, formatMcpStatus, parseKbCommand, parseSkillCommand, tokenize } from "./slash";
56
+ import { gcArtifacts, collectReferencedArtifacts } from "@taicho-ai/framework/store/artifacts";
57
+ import { syncKnowledgeSources } from "@taicho-ai/framework/knowledge/sync";
58
+ import { listNodeRows, forgetNodes, reindexKnowledge, reembedAll } from "@taicho-ai/framework/store/knowledge";
59
+ import { listSkills, readSkill, deleteSkill, reindexSkills } from "@taicho-ai/framework/store/skills";
60
+ import { getViewMode, setViewMode as persistViewMode, isViewMode, VIEW_MODES, getPlanPanel, setPlanPanel as persistPlanPanel, type ViewMode } from "@taicho-ai/framework/store/prefs";
61
+
62
+ /** One pending approval request in the queue: a stable id (the card's React key — stays fixed while
63
+ * this request is the head, so queuing a sibling behind it never remounts the visible card), the
64
+ * request, and the resolver of the tool's blocked promise. Plan 04 makes concurrent approvals
65
+ * possible (a background run and the foreground run can both block on the captain at once), so
66
+ * approvals are QUEUED — never a single clobberable slot. */
67
+ type PendingApproval = { id: number; req: ApprovalRequest; resolve: (d: ApprovalDecision) => void };
68
+
69
+ /** Default global background-run ceiling (total in-flight + queued dispatched tasks) when taicho.yaml
70
+ * doesn't set `tasks.maxBackgroundRuns`. Bounds a model-initiated dispatch chain that resets
71
+ * per-request budgets on each hop; over-ceiling dispatch is refused, not silently unbounded. */
72
+ const DEFAULT_BACKGROUND_RUN_CEILING = 32;
73
+
74
+ /** How often the REPL evaluates schedules for firing (Plan 04 Phase 6). The tick rate — not any
75
+ * schedule's interval — is the real floor on how often a scheduled run can fire in a live session,
76
+ * which naturally bounds a too-eager `--every`. */
77
+ const SCHEDULE_TICK_MS = 15_000;
78
+
79
+ type ResolveModelFn = (agentId: string) => { model: Model; modelId: string; subscription?: boolean; captureCost?: boolean };
80
+ type PriceFn = (u: { inputTokens: number; outputTokens: number }) => number;
81
+ interface BuiltAuth { model: Model | null; resolveModel?: ResolveModelFn; priceUsd?: PriceFn }
82
+
83
+ /** Animated run indicator: @inkjs/ui Spinner (owns its own glyph animation) + the live activity
84
+ * (which agent is doing what) + elapsed seconds + the controls hint. A light ticker re-renders only
85
+ * to advance the elapsed-seconds readout. */
86
+ function RunStatus({ activity }: { activity: string }) {
87
+ const [, setTick] = useState(0);
88
+ const started = useRef(Date.now());
89
+ useEffect(() => {
90
+ const t = setInterval(() => setTick((n) => n + 1), 200);
91
+ return () => clearInterval(t);
92
+ }, []);
93
+ const secs = ((Date.now() - started.current) / 1000).toFixed(1);
94
+ return (
95
+ <Box>
96
+ <Spinner label={activity} />
97
+ <Text color="gray">{` ${secs}s `}</Text>
98
+ <Text dimColor>esc to cancel · type to steer</Text>
99
+ </Box>
100
+ );
101
+ }
102
+
103
+ /** One committed scrollback line. Rendered inside <Static> (write-once → the terminal's own scrollback),
104
+ * so it takes its spacing precomputed (annotateSpacing) instead of peeking at a neighbour. Three shapes
105
+ * match transcript-style.ts's tiers: a full-width user bar, an agent reply (bold speaker + markdown), or
106
+ * a system op/notice (dim │ rail for ops). */
107
+ function TranscriptRow({ item, mdWidth, termCols }: { item: SpacedLine; mdWidth: number; termCols: number }) {
108
+ const { line: l, marginTop: mt, newBlock } = item;
109
+ if (l.rendered) {
110
+ // An agent reply: the bold cyan speaker once per reply (on the block boundary), then the markdown.
111
+ return (
112
+ <Box flexDirection="column" marginTop={mt}>
113
+ {newBlock && l.from && <Text color="cyan" bold>{"● " + l.from}</Text>}
114
+ {renderMarkdown(l.text, mdWidth).split("\n").map((ln, j) => (
115
+ <Text key={j}>{ln}</Text>
116
+ ))}
117
+ </Box>
118
+ );
119
+ }
120
+ if (l.kind === "user") {
121
+ // The turn anchor: a full-width inverse bar spanning the row edge-to-edge, wrapped to the width.
122
+ const bar = userBarLines(l.text, termCols);
123
+ return (
124
+ <Box flexDirection="column" marginTop={mt}>
125
+ {bar.map((bl, j) => (
126
+ <Text key={j} inverse bold>{bl}</Text>
127
+ ))}
128
+ </Box>
129
+ );
130
+ }
131
+ // A system line: an operation breadcrumb (│ rail + category colour) or a plain notice.
132
+ const s = classifySystemLine(l.text);
133
+ return (
134
+ <Box marginTop={mt}>
135
+ <Text>
136
+ {s.isOp && <Text color="gray" dimColor>{"│ "}</Text>}
137
+ <Text color={s.color} dimColor={s.dim}>{s.text}</Text>
138
+ </Text>
139
+ </Box>
140
+ );
141
+ }
142
+
143
+ /** A Static item: the one-time banner, or a committed transcript line. */
144
+ type StaticRow = { kind: "banner" } | { kind: "line"; sp: SpacedLine };
145
+
146
+ /** Title + fields for the non-question approval cards. */
147
+ function proposalView(req: Exclude<ApprovalRequest, { kind: "ask_human" }>): { title: string; fields: CardField[] } {
148
+ if (req.kind === "propose_coaching")
149
+ return { title: "New coaching note — approve?", fields: [
150
+ { label: "when", value: req.draft.when }, { label: "do", value: req.draft.do }, { label: "scope", value: req.draft.scope },
151
+ ] };
152
+ if (req.kind === "add_mcp") {
153
+ const transport = isStdioServer(req.spec) ? `${req.spec.command} ${(req.spec.args ?? []).join(" ")}`.trim() : req.spec.url;
154
+ // Show only the NAMES of env vars / headers (never their secret values). For http, include auth + header
155
+ // names + the env-var names that will be written to process.env.
156
+ const env = isStdioServer(req.spec)
157
+ ? Object.keys(req.spec.env ?? {}).join(", ")
158
+ : [...(req.spec.auth ? [req.spec.auth] : []), ...Object.keys(req.spec.headers ?? {}), ...Object.keys(req.spec.env ?? {})].join(", ");
159
+ return { title: "Add MCP server — approve?", fields: [
160
+ { label: "name", value: req.name }, { label: "transport", value: transport }, { label: "env", value: env || "—" },
161
+ ] };
162
+ }
163
+ if (req.kind === "propose_skill")
164
+ return { title: "New skill — approve?", fields: [
165
+ { label: "name", value: req.draft.name },
166
+ { label: "when", value: req.draft.description },
167
+ { label: "procedure", value: req.draft.body },
168
+ ] };
169
+ if (req.kind === "run_command")
170
+ return { title: "Run command — approve?", fields: [
171
+ { label: "command", value: req.command },
172
+ { label: "cwd", value: req.cwd ?? "(workspace)" },
173
+ { label: "flagged", value: req.reason ?? "the guard flagged this command" },
174
+ ] };
175
+ if (req.kind === "propose_workflow") {
176
+ const summary = req.draft.steps.map((raw) => {
177
+ const s = raw as Record<string, unknown>;
178
+ const kind = s.run ? `@${String(s.run).replace(/^@/, "")}`
179
+ : s.check ? "check" : s.human ? `✋${String(s.human)}` : s.branch ? "branch"
180
+ : (s.over || s.branches) ? "parallel" : "?";
181
+ return `${String(s.id ?? "?")}(${kind})`;
182
+ }).join(" → ");
183
+ return { title: `Propose workflow "${req.draft.name}" for ${req.draft.team} — approve?`, fields: [
184
+ { label: "team", value: req.draft.team },
185
+ { label: "brief", value: req.draft.brief ?? "(none)" },
186
+ { label: "steps", value: summary || "(none)" },
187
+ ] };
188
+ }
189
+ if (req.kind === "create_team")
190
+ return { title: "New team — approve?", fields: [
191
+ { label: "id", value: req.draft.id },
192
+ { label: "charter", value: req.draft.charter },
193
+ { label: "lead", value: req.draft.lead ?? "(routed by capability)" },
194
+ { label: "members", value: req.draft.members.join(", ") || "(none yet)" },
195
+ { label: "grant", value: req.draft.grant.join(", ") || "(none beyond what members hold)" },
196
+ ] };
197
+ return { title: "New agent — approve?", fields: [
198
+ { label: "id", value: req.draft.id }, { label: "role", value: req.draft.role }, { label: "identity", value: req.draft.identity },
199
+ ] };
200
+ }
201
+
202
+ export function App(props: {
203
+ ws: string; db: Database; model: Model | null; roster: RegistryRow[];
204
+ cfg: { provider: string; model: string } | null;
205
+ priceUsd?: PriceFn;
206
+ resolveModel?: ResolveModelFn;
207
+ configDefaults?: TaichoConfig["defaults"];
208
+ backgroundRunCeiling?: number; // Plan 04: global in-flight+queued dispatch ceiling (default 32)
209
+ authSource: AuthSource;
210
+ buildFromAuth: (s: AuthSource) => BuiltAuth;
211
+ onLogin: () => Promise<AuthSource>;
212
+ onLogout: () => boolean;
213
+ rootThread?: ModelMessage[];
214
+ mcp?: McpManager;
215
+ mcpYamlServers?: string[];
216
+ embed?: (text: string) => Promise<Float32Array>;
217
+ startupNotice?: string;
218
+ spendLedger?: SpendLedger; // Plan 09: squad-wide spend ledger (undefined ⇒ no squad ceilings configured)
219
+ telemetry?: Telemetry; // Plan 16: OpenTelemetry handle (undefined ⇒ OTLP export off)
220
+ viewMode?: ViewMode; // Plan 10: initial live-view mode (defaults to the persisted pref / `both`)
221
+ terminalSize?: { columns: number; rows: number }; // Plan 10: authoritative size seam (tests/embeds); else live stdout
222
+ }) {
223
+ const { exit } = useApp();
224
+ const { stdout } = useStdout();
225
+ // Terminal size is reactive so the bar + panes (and markdown wrap) re-flow on a resize. An explicit
226
+ // prop is authoritative (tests/embeds); otherwise we track the live stdout and its "resize" events.
227
+ const liveSize = () => ({ columns: stdout?.columns ?? 80, rows: (stdout as { rows?: number })?.rows ?? 24 });
228
+ const [termSize, setTermSize] = useState(() => props.terminalSize ?? liveSize());
229
+ useEffect(() => {
230
+ if (props.terminalSize) { setTermSize(props.terminalSize); return; }
231
+ const update = () => setTermSize(liveSize());
232
+ update();
233
+ stdout?.on?.("resize", update);
234
+ return () => { stdout?.off?.("resize", update); };
235
+ // eslint-disable-next-line react-hooks/exhaustive-deps
236
+ }, [stdout, props.terminalSize]);
237
+ const mdWidth = termSize.columns - 2; // small margin so wrapping never hugs the edge
238
+ // Plan 10: the live view mode (bar/panes/both), seeded from the persisted pref. /view switches it.
239
+ const [viewMode, setViewMode] = useState<ViewMode>(() => props.viewMode ?? getViewMode(props.ws));
240
+ // Plan 18: the live plan, fed by the phase-less `plan` step event. Null ⇒ the panel collapses to
241
+ // nothing. Seeded from the store so a plan SURVIVES A RESTART visibly — it is on disk, and a plan
242
+ // you cannot see is a plan you cannot steer. (reconcilePlans has already marked in-flight items
243
+ // interrupted by the time this runs.)
244
+ const [plan, setPlan] = useState<PlanState | null>(() => {
245
+ const id = currentPlanId(props.db, "root");
246
+ return id ? foldPlan(props.ws, id) : null;
247
+ });
248
+ const [planPanelOn, setPlanPanelOn] = useState<boolean>(() => getPlanPanel(props.ws));
249
+ // Seed the visible scrollback from the SAME replay thread the model gets (props.rootThread), so a
250
+ // resumed session opens on its history instead of a blank screen. What you see == what root remembers.
251
+ const [lines, setLines] = useState<Line[]>(() => [...initialLines(props), ...threadToLines(props.rootThread ?? [])]);
252
+ // Bumped by /clear to remount <Static>: a committed line lives in native scrollback and can't be
253
+ // un-written, so /clear wipes the terminal AND resets Static's render counter (fresh key) so it
254
+ // re-emits the post-clear items from the top instead of thinking it already drew them.
255
+ const [clearEpoch, setClearEpoch] = useState(0);
256
+ // Plan 24: the message editor is now a CONTROLLED <ChatInput> — `input` is the single source of truth,
257
+ // so a programmatic set (clear-on-submit, seed "/cmd " on accept) is just `setInput`, and the draft
258
+ // survives the browser dock's unmount without the old remount hack (bumped key + seed).
259
+ const [input, setInput] = useState("");
260
+ const [history, setHistory] = useState<string[]>(() => loadHistory(props.ws)); // ↑/↓ message history
261
+ const [selected, setSelected] = useState(0);
262
+ const [activity, setActivity] = useState("working…"); // live status shown by the run spinner
263
+ const [busy, setBusy] = useState(false);
264
+ // A QUEUE of pending approval requests (not a single slot): with Plan 04, a background dispatched
265
+ // run and the foreground run can both block on the captain at the same time. Each request appends
266
+ // here; the card renders the HEAD; answering resolves THAT request's promise and pops to the next.
267
+ // A single `pending` slot would let the second setPending clobber the first's resolver (lost forever).
268
+ const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
269
+ const approvalSeq = useRef(0); // monotonic id source so each queued approval has a stable card key
270
+ const pending = pendingApprovals[0] ?? null; // the head request currently shown on the card
271
+ const [roster, setRoster] = useState(props.roster);
272
+ // Live auth: model/resolver/pricer are STATE (seeded from props) so /login can re-arm the REPL
273
+ // without a restart. deps() reads these, so the next submit picks up the new credentials.
274
+ const [authSource, setAuthSource] = useState<AuthSource>(props.authSource);
275
+ const [model, setModel] = useState<Model | null>(props.model);
276
+ const [resolveModel, setResolveModel] = useState<ResolveModelFn | undefined>(() => props.resolveModel);
277
+ const [priceUsd, setPriceUsd] = useState<PriceFn | undefined>(() => props.priceUsd);
278
+ // Plan 04 Phase 4: per-run steer routing replaces the single global queue. steerRoutes maps a
279
+ // runId → its pending steers; activeRuns tracks which agent each live run belongs to (for @agent
280
+ // routing); foregroundRootRef is the current watched turn's root run (plain steer target).
281
+ const steerRoutes = useRef<Map<string, string[]>>(new Map());
282
+ const activeRuns = useRef<Map<string, { agent: string; triggeredBy: string }>>(new Map());
283
+ const foregroundRootRef = useRef<string | null>(null);
284
+ // Plan 04 Phase 2/3: the background task scheduler (persistent queue + per-agent concurrency cap).
285
+ const schedulerRef = useRef<TaskScheduler | null>(null);
286
+ if (!schedulerRef.current) schedulerRef.current = new TaskScheduler({ globalCap: props.backgroundRunCeiling ?? DEFAULT_BACKGROUND_RUN_CEILING });
287
+ const scheduler = schedulerRef.current;
288
+ // Plan 04 Phase 6: the schedule/trigger runner. It fires an UNATTENDED run through the headless
289
+ // `executeRun` path (runHeadless — NOT the Ink approval card) whenever a schedule comes due. The
290
+ // fire closure is held in a ref so it always sees the current model/deps (re-armed on /login).
291
+ const fireScheduleRef = useRef<(s: Schedule) => Promise<void>>(async () => {});
292
+ const schedRunnerRef = useRef<SchedulerRunner | null>(null);
293
+ if (!schedRunnerRef.current) schedRunnerRef.current = new SchedulerRunner({
294
+ now: () => Date.now(),
295
+ statMtimeMs: (p) => { try { return statSync(p).mtimeMs; } catch { return null; } },
296
+ fire: (s) => fireScheduleRef.current(s),
297
+ // The runner advances scheduling state (lastRunAt/nextDueAt/runCount) on each fire; persist it as a
298
+ // field patch so cadence survives a restart and never clobbers lastRunId/lastStatus (set by fire).
299
+ persist: (id, patch) => { updateSchedule(props.ws, id, patch); },
300
+ });
301
+ const schedRunner = schedRunnerRef.current;
302
+ const thread = useRef<ModelMessage[]>(props.rootThread ?? []);
303
+ const aborter = useRef<AbortController | null>(null);
304
+ // The active approval/question card publishes its key handler here (during its render). App's one
305
+ // boot-registered useInput forwards to it while a card is up — see the useInput below.
306
+ const cardKeyRef = useRef<CardKeyHandler | null>(null);
307
+ // Live streaming: text deltas accumulate in streamRef (authoritative, dodges stale closures). As
308
+ // whole markdown blocks close they commit as rendered (white) lines; the still-growing tail is
309
+ // held back — never shown raw — until it closes into a block (or the run ends and flushStream
310
+ // renders it). The spinner (RunStatus) is the "working" signal while a block is mid-stream.
311
+ // streamedRef records whether ANY delta arrived this run, so we only fall back to res.text for
312
+ // non-streaming (env-key) providers.
313
+ const streamRef = useRef("");
314
+ const streamFromRef = useRef("");
315
+ const streamedRef = useRef(false);
316
+ const streamBlocksRef = useRef(0); // how many completed streamed blocks we've committed this run
317
+ const streamRunIdRef = useRef<string | undefined>(undefined); // Plan 13: track which run is streaming (so flushStream can gate on root)
318
+ // Plan 10 live status: statusRef is the authoritative per-run status map (dodges stale closures in
319
+ // the rapid onStep stream); `statuses` is the rendered snapshot.
320
+ const statusRef = useRef<StatusMap>(new Map());
321
+ const [statuses, setStatuses] = useState<AgentStatus[]>([]);
322
+ const clearStatuses = () => { statusRef.current = new Map(); setStatuses([]); };
323
+ // Plan 10 Phase 4: per-run activity feed for the split-pane view (recent tool lines + the live
324
+ // streamed/final text), built off the SAME event stream the status map is. Reset at each new turn.
325
+ const PANE_FEED_LINES = 4;
326
+ const paneFeedRef = useRef<Map<string, PaneEntry>>(new Map());
327
+ const [paneFeed, setPaneFeed] = useState<PaneFeedMap>(new Map());
328
+ const clearPaneFeed = () => { paneFeedRef.current = new Map(); setPaneFeed(new Map()); };
329
+ const bumpPaneFeed = (runId: string, mut: (e: PaneEntry) => PaneEntry) => {
330
+ const cur = paneFeedRef.current.get(runId) ?? { lines: [] };
331
+ const next = new Map(paneFeedRef.current);
332
+ next.set(runId, mut(cur));
333
+ paneFeedRef.current = next;
334
+ setPaneFeed(next);
335
+ };
336
+ // Plan 13 (corrected): per-run block data for the consistent agent blocks. Each agent (root and
337
+ // sub-agents) is rendered as a single block: header + fixed 2-line body. The block IS the record —
338
+ // the block you watched live is the exact block that settles into scrollback. Built off the SAME
339
+ // `delta` events the status map reads — no new engine plumbing. Display-only: it never touches
340
+ // transcript/ledger/replay (the full text is still recorded on disk — this bounds the SCREEN).
341
+ const BLOCK_KEEP_LINES = 5; // keep a few lines for the rolling tail inside the block body
342
+ const blockFeedRef = useRef<Map<string, { lines: string; agent: string; parentRunId?: string; depth: number }>>(new Map());
343
+ const [blockFeed, setBlockFeed] = useState<Map<string, { lines: string; agent: string; parentRunId?: string; depth: number }>>(new Map());
344
+ const clearBlockFeed = () => { blockFeedRef.current = new Map(); setBlockFeed(new Map()); };
345
+ const bumpBlockFeed = (runId: string, delta: string, agent: string, parentRunId?: string, depth = 0) => {
346
+ const cur = blockFeedRef.current.get(runId);
347
+ const merged = (cur?.lines ?? "") + delta;
348
+ // Trim to the last BLOCK_KEEP_LINES lines so the accumulator stays bounded
349
+ const segs = merged.split("\n");
350
+ const trimmed = segs.length > BLOCK_KEEP_LINES ? segs.slice(-BLOCK_KEEP_LINES).join("\n") : merged;
351
+ const next = new Map(blockFeedRef.current);
352
+ next.set(runId, { lines: trimmed, agent, parentRunId, depth });
353
+ blockFeedRef.current = next;
354
+ setBlockFeed(next);
355
+ };
356
+
357
+ // Plan 13: focus state for block navigation. shift+tab enters focus mode, ↑↓ move, ⏎ opens, esc leaves.
358
+ const [focusMode, setFocusMode] = useState(false);
359
+ const [focusIndex, setFocusIndex] = useState(0);
360
+ const [operationRunId, setOperationRunId] = useState<string | null>(null);
361
+
362
+ // Plan 21: the artifact browser. Runs END inside it — a COMPLETED foreground turn with ≥1 artifact
363
+ // docks the shelf over the chat (replacing Plan 15's completion action bar + viewer). ALL of its UI
364
+ // state lives HERE so a pending approval card can unmount the dock and remount it losslessly. The
365
+ // keyboard rides browserKeyRef — its own ref, never cardKeyRef — behind a fixed dispatch order in
366
+ // useInput (pending card → operation view → browser → chat), so `y` can only ever answer the
367
+ // surface that is actually on screen.
368
+ const [browser, setBrowser] = useState<{ rootRunId?: string; ui: BrowserUiState; bump: number } | null>(null);
369
+ const browserKeyRef = useRef<CardKeyHandler | null>(null);
370
+ const lastRunRef = useRef<string | undefined>(undefined); // last completed foreground run (bare /artifacts re-entry)
371
+ // Dock the browser. Plan 24: the draft lives in the controlled `input` state, so it survives the
372
+ // input's unmount here automatically — no remount seed needed.
373
+ const dockBrowser = (b: { rootRunId?: string; ui: BrowserUiState; bump: number }) => {
374
+ setBrowser(b);
375
+ };
376
+
377
+ // Plan 22: the Org browser — the captain's dedicated team/agent management mode (/teams, /agents).
378
+ // Same discipline as the artifact browser: all UI state lives HERE, keys ride orgKeyRef, and a pending
379
+ // card suspends it. It docks over the chat (input + panes + blocks + plan panel yield while it's up).
380
+ const [org, setOrg] = useState<{ ui: OrgUiState; bump: number } | null>(null);
381
+ const orgKeyRef = useRef<CardKeyHandler | null>(null);
382
+ const dockOrg = (scope: OrgScope) => {
383
+ setOrg({ ui: initialOrgUi(scope), bump: 0 });
384
+ };
385
+ // Plan 25: the /workflows browser — read-only inspection of team workflows + their run state. Same
386
+ // docking discipline as the Org browser (state here, keys ride workflowsKeyRef, a card suspends it).
387
+ const [workflows, setWorkflows] = useState<{ ui: WorkflowUiState; bump: number } | null>(null);
388
+ const workflowsKeyRef = useRef<CardKeyHandler | null>(null);
389
+ const dockWorkflows = () => {
390
+ setWorkflows({ ui: initialWorkflowUi(), bump: 0 });
391
+ };
392
+ // Each mutation runs a service-layer call, refreshes the derived roster (StatusBar/prompt read it), and
393
+ // reports ok/error back to the browser for its transient note line.
394
+ const orgAction = async (fn: () => Promise<unknown>) => {
395
+ try { await fn(); setRoster(loadIndex(props.db)); setOrg((o) => (o ? { ...o, bump: o.bump + 1 } : o)); return { ok: true }; }
396
+ catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }
397
+ };
398
+ const orgActions: OrgActions = {
399
+ createTeam: (draft, members) => orgAction(() => createTeamWithMembers(props.ws, props.db, { id: draft.id, charter: draft.charter, lead: draft.lead }, members)),
400
+ createAgent: (draft) => orgAction(() => createAgent(props.ws, props.db, { id: draft.id, role: draft.role, identity: draft.identity, teams: draft.teams }, "captain", props.configDefaults)),
401
+ deleteTeam: (id) => orgAction(() => deleteTeam(props.ws, props.db, id)),
402
+ deleteAgent: (id) => orgAction(() => deleteAgent(props.ws, props.db, id)),
403
+ setTeamMembers: (teamId, members) => orgAction(() => setTeamMembers(props.ws, props.db, teamId, members)),
404
+ setAgentTeams: (agentId, teams) => orgAction(() => setAgentTeams(props.ws, props.db, agentId, teams)),
405
+ scaffoldWorkflow: (teamId, members) => orgAction(async () => scaffoldWorkflow(props.ws, teamId, members)),
406
+ };
407
+
408
+ // Plan 13 (corrected): compute block data from the block feed at the top level (hooks must not be
409
+ // called inside conditionals or IIFEs). The consistent block view shows every AGENT (sub-agents,
410
+ // delegated work) as a fixed-height block. Root's direct reply still uses the scrollback (the
411
+ // conversational reply channel); blocks are for the squad, not the root's own answer.
412
+ const now = Date.now();
413
+ const liveBlocks: AgentBlockData[] = [];
414
+ for (const [runId, data] of blockFeed) {
415
+ // Skip root's direct reply — it goes to scrollback, not blocks
416
+ if (runId === foregroundRootRef.current) continue;
417
+ const status = statuses.find((s) => s.runId === runId);
418
+ const variant = status ? "live" : "done";
419
+ liveBlocks.push({
420
+ runId,
421
+ agent: data.agent,
422
+ state: status?.state ?? "idle",
423
+ since: status?.since ?? now,
424
+ waiting: status?.waiting ?? false,
425
+ lines: tailLines(data.lines, 3),
426
+ variant,
427
+ depth: data.depth,
428
+ });
429
+ }
430
+ const { allBlocks } = useBlockSettle(liveBlocks);
431
+ useBlockTicker(allBlocks.length > 0);
432
+
433
+ // The SQUAD surfaces (status bar + split panes) show the SQUAD — delegated/background agents — not
434
+ // the foreground turn's OWN run. The blocks already apply this rule (they skip foregroundRootRef
435
+ // above); the bar and panes never did, so a solo root turn rendered "root thinking" in BOTH the
436
+ // pane and the bar, on top of the busy spinner (which is root's real control surface: cancel/steer)
437
+ // and the scrollback breadcrumb. The foreground run (root, or an @agent turn — both are
438
+ // foregroundRootRef) is now filtered out of both, so a solo turn no longer duplicates itself and a
439
+ // delegation lights the surfaces for the CHILDREN. A background agent (never foregroundRootRef,
440
+ // even while it holds an approval) still shows — that's the "the squad is stalled on YOU" signal.
441
+ const squadStatuses = statuses.filter((s) => s.runId !== foregroundRootRef.current);
442
+
443
+ // The live suggester: which commands match what's being typed (empty once past the command name).
444
+ const sugg = suggestCommands(input);
445
+
446
+ // Graceful shutdown: close MCP servers + flush telemetry spans, then unmount. Shared by Esc, Ctrl+C
447
+ // and the typed `exit`/`quit` commands so the teardown can never drift between them.
448
+ const quit = () => { void (async () => { await props.mcp?.closeAll(); await props.telemetry?.shutdown(); exit(); })(); };
449
+
450
+ useInput((input, key) => {
451
+ // Ctrl+C quits from ANY surface (card, browser, focus mode, chat). Ink's built-in exitOnCtrlC is
452
+ // disabled at render() because it only matches the legacy \x03 byte — under the kitty keyboard
453
+ // protocol Ctrl+C arrives as \x1b[99;5u, which Ink decodes to `input:'c', key.ctrl:true` (the same
454
+ // shape it gives for legacy \x03), so this ONE check handles both. Mirror the Esc-idle graceful quit.
455
+ if (key.ctrl && input === "c") {
456
+ quit();
457
+ return;
458
+ }
459
+ // While a card is up, this boot-registered useInput is the only listener guaranteed to be wired
460
+ // when the captain's first keystroke arrives, so we forward it to the active card. (A card-owned
461
+ // useInput registers a beat after its render commits and would drop that first key — the hang
462
+ // we're fixing.) The card publishes its handler to cardKeyRef during render.
463
+ if (pending || operationRunId) { cardKeyRef.current?.(input, key); return; } // a card OR the operation view owns the keyboard
464
+ // Plan 21: the docked browser is NEXT in precedence — a pending card above suspends it entirely
465
+ // (its render is gated on !pending too), so a keystroke can never be ambiguous between surfaces.
466
+ if (browser) { browserKeyRef.current?.(input, key); return; }
467
+ // Plan 22: the Org browser is next — same suspension rule as the artifact browser.
468
+ if (org) { orgKeyRef.current?.(input, key); return; }
469
+ // Plan 25: the workflows browser is next in precedence (a card / artifact browser / org above wins).
470
+ if (workflows) { workflowsKeyRef.current?.(input, key); return; }
471
+ // Plan 13: focus mode navigation. shift+tab enters, esc leaves, ↑↓ move, ⏎ opens operation view.
472
+ // Plan 20: the ring and the Enter target read the SAME collection — allBlocks is what RENDERS
473
+ // (root excluded, settling-first). The old Enter path indexed [...blockFeed.keys()] (insertion
474
+ // order, root INCLUDED since root's deltas populate the feed), so highlighting block 0 could
475
+ // open root's run instead of the highlighted child; ↑↓ also bounded on blockFeed.size.
476
+ if (focusMode) {
477
+ if (key.escape) { setFocusMode(false); setFocusIndex(0); return; }
478
+ if (key.upArrow) { setFocusIndex((i) => Math.max(0, i - 1)); return; }
479
+ if (key.downArrow) { setFocusIndex((i) => Math.min((allBlocks.length || 1) - 1, i + 1)); return; }
480
+ if (key.return) {
481
+ const target = allBlocks[focusIndex];
482
+ if (target) setOperationRunId(target.runId);
483
+ return;
484
+ }
485
+ return; // consume other keys while in focus mode
486
+ }
487
+ if (key.shift && key.tab) { setFocusMode(true); return; } // enter focus mode
488
+ if (key.escape) { if (busy) { aborter.current?.abort(); say({ kind: "system", text: " ⊗ cancelling…" }); } else { quit(); } return; }
489
+ // Plan 24: chat-line editing (typing, motions, word-nav, history) AND the suggester's ↑/↓/Tab now
490
+ // live in <ChatInput>'s own useInput (gated by isActive). This boot listener only owns the surfaces
491
+ // above (cards, browsers, focus, escape/shift-tab).
492
+ }, { isActive: true });
493
+
494
+ const say = (l: Line) => setLines((prev) => [...prev, l]);
495
+
496
+ useEffect(() => {
497
+ if (props.startupNotice) say({ kind: "system", text: ` ${props.startupNotice}` });
498
+ // eslint-disable-next-line react-hooks/exhaustive-deps
499
+ }, []);
500
+
501
+ // Plan 04 Phase 6: fire a due schedule through the headless `executeRun` path (runHeadless, NOT the
502
+ // Ink approval card). Rebuilt every render so it closes over the CURRENT model/resolver (re-armed on
503
+ // /login). A scheduled run is UNATTENDED: its approve mode (default `reject`) drives an auto-reject
504
+ // approval channel — no captain, so no unsupervised privileged exec. Breadcrumbs route to scrollback.
505
+ fireScheduleRef.current = async (s: Schedule) => {
506
+ const activeModel = model;
507
+ if (!activeModel) { say({ kind: "system", text: ` ⏰ schedule ${s.id} skipped — no model configured` }); return; }
508
+ say({ kind: "system", text: ` ⏰ schedule ${s.id} firing → ${s.agent}: ${s.goal} (approvals: ${s.approve})` });
509
+ try {
510
+ const res = await runHeadless(
511
+ { ws: props.ws, db: props.db, model: activeModel, resolveModel, priceUsd, configDefaults: props.configDefaults, mcp: props.mcp, embed: props.embed, spendLedger: props.spendLedger, telemetry: props.telemetry },
512
+ // scheduleFireOptions stamps triggeredBy: schedule:<id> — keeps this UNATTENDED fire OUT of the
513
+ // target agent's conversation ledger + boot-replay cache (it still gets full run evidence).
514
+ // Otherwise every cron/interval/watch fire appended a user+assistant pair and replayed as prior
515
+ // "conversation" on the next launch. Shared with index.tsx so the wiring can't drift.
516
+ { ...scheduleFireOptions(s), out: (l) => say({ kind: "system", text: ` ${l}` }) },
517
+ );
518
+ // Record the outcome of this fire (lastRunId/lastStatus). The runner already advanced cadence.
519
+ updateSchedule(props.ws, s.id, { lastRunId: res.runId, lastStatus: res.outcome ?? (res.ok ? "completed" : "failed") });
520
+ say({ kind: "system", text: ` ${res.ok ? "✓" : "⚠"} schedule ${s.id} ${res.outcome ?? (res.ok ? "done" : "failed")}${res.runId ? ` — run ${res.runId}` : ""} — /schedules` });
521
+ } catch (e) {
522
+ updateSchedule(props.ws, s.id, { lastStatus: "failed" });
523
+ say({ kind: "system", text: ` ⚠ schedule ${s.id} failed — ${e instanceof Error ? e.message : String(e)}` });
524
+ }
525
+ };
526
+
527
+ // Arm persisted schedules on boot (reconcile — they survive a restart) and tick the runner on a
528
+ // real interval. The timer is unref'd so it never keeps the process (or a test's event loop) alive.
529
+ useEffect(() => {
530
+ for (const s of listSchedules(props.ws)) schedRunner.add(s);
531
+ const t = setInterval(() => { try { schedRunner.tick(); } catch { /* a bad schedule never wedges the REPL */ } }, SCHEDULE_TICK_MS);
532
+ (t as { unref?: () => void }).unref?.();
533
+ return () => clearInterval(t);
534
+ // eslint-disable-next-line react-hooks/exhaustive-deps
535
+ }, []);
536
+
537
+ // Commit the in-progress streamed text (if any) as a finalized agent line — called when a tool
538
+ // interrupts the stream and at run end. No-op when nothing has streamed.
539
+ // Plan 13 (corrected): only commit root's direct reply AND foreground @agent turns to scrollback;
540
+ // background dispatched runs stay in blocks.
541
+ const flushStream = () => {
542
+ if (streamRef.current) {
543
+ const runEntry = streamRunIdRef.current ? activeRuns.current.get(streamRunIdRef.current) : undefined;
544
+ const isForegroundRun = !runEntry || runEntry.triggeredBy === "user";
545
+ const isRootReply = streamFromRef.current === "root" || isForegroundRun;
546
+ if (isRootReply) {
547
+ const { blocks, tail } = splitCompletedBlocks(streamRef.current);
548
+ if (blocks.length > streamBlocksRef.current) for (const b of blocks.slice(streamBlocksRef.current)) say({ kind: "agent", from: streamFromRef.current, text: b, rendered: true });
549
+ if (tail.trim()) say({ kind: "agent", from: streamFromRef.current, text: tail, rendered: true });
550
+ }
551
+ }
552
+ streamRef.current = ""; streamBlocksRef.current = 0; streamRunIdRef.current = undefined;
553
+ };
554
+
555
+ // Run the highlighted command now (no arg) or fill `/<cmd> ` so the captain can type its argument.
556
+ const acceptSuggestion = (list: SlashCommand[]) => {
557
+ const cmd = list[Math.min(selected, list.length - 1)];
558
+ if (!cmd) return;
559
+ setSelected(0);
560
+ if (cmd.requiresArg) { setInput(`/${cmd.name} `); return; }
561
+ setInput("");
562
+ say({ kind: "user", text: `/${cmd.name}` });
563
+ void runSlash(cmd.name, "");
564
+ };
565
+ // Best-effort: a failed run whose surfaced text reflects an AuthExpiredError ("session expired")
566
+ // gets an explicit nudge to re-run /login openai (the run already returned a failed outcome).
567
+ const maybeSayAuthExpired = (text: string) => {
568
+ if (/session expired/i.test(text)) say({ kind: "system", text: ` ${authExpiredMessage()}` });
569
+ };
570
+
571
+ // Enqueue an approval request and hand back a promise that resolves ONLY when the captain answers
572
+ // THIS request. Concurrent requests queue behind the head; each keeps its own resolver, so none is
573
+ // clobbered. `answerHead` (below) resolves the head's promise and pops it off.
574
+ const requestApproval = (req: ApprovalRequest) =>
575
+ new Promise<ApprovalDecision>((resolve) => {
576
+ const id = ++approvalSeq.current;
577
+ setPendingApprovals((q) => [...q, { id, req, resolve }]);
578
+ });
579
+
580
+ // Answer the head approval: resolve its promise, drop it, and let the next queued request surface.
581
+ // cardKeyRef is cleared so the next card re-registers its own key handler on mount.
582
+ const answerHead = (d: ApprovalDecision) => {
583
+ cardKeyRef.current = null;
584
+ setPendingApprovals((q) => {
585
+ const [head, ...rest] = q;
586
+ head?.resolve(d);
587
+ return rest;
588
+ });
589
+ };
590
+
591
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
592
+ // Race a promise against a timeout that ALWAYS clears its timer — a bare Promise.race([p, sleep(ms)])
593
+ // leaves the losing setTimeout ref'd, keeping the event loop alive (and delaying Esc-quit) for up to
594
+ // `ms`. Returns true if `p` settled first, false on timeout.
595
+ const raceWithTimeout = (p: Promise<unknown>, ms: number): Promise<boolean> => {
596
+ let timer: ReturnType<typeof setTimeout> | undefined;
597
+ const timeout = new Promise<boolean>((r) => { timer = setTimeout(() => r(false), ms); });
598
+ return Promise.race([p.then(() => true), timeout]).finally(() => { if (timer) clearTimeout(timer); });
599
+ };
600
+ const taskSummary = (id: string): TaskAwaitResult => {
601
+ const t = readTaskState(props.ws, id);
602
+ if (!t) return { status: "unknown", error: `no task "${id}"` };
603
+ return { status: t.status, summary: t.summary, resultRef: t.resultRef, runId: t.rootRunId || undefined };
604
+ };
605
+
606
+ // A background task settled — record its outcome + a reference (NOT the payload) and notify the
607
+ // captain. A cancelled task keeps its cancelled status (the interrupted outcome doesn't override it).
608
+ const settleTask = (taskId: string, agentId: string, res: RunResult) => {
609
+ const wasCancelled = readTaskState(props.ws, taskId)?.status === "cancelled";
610
+ const children = res.trace.delegatedOut.map((id) => readTrace(props.ws, id));
611
+ updateTaskFromTrace(props.ws, taskId, res.trace, children, props.db);
612
+ const resultRef = res.trace.artifacts[0] ?? res.runId; // hand-off BY REFERENCE (handle or run id)
613
+ setTaskFields(props.ws, props.db, taskId, { resultRef, summary: res.text, rootRunId: res.runId, ...(wasCancelled ? { status: "cancelled" } : {}) });
614
+ const status = readTaskState(props.ws, taskId)?.status ?? "completed";
615
+ const icon = status === "completed" ? "✓" : status === "cancelled" ? "⊗" : "⚠";
616
+ say({ kind: "system", text: ` ${icon} background task ${taskId} (${agentId}) ${status} — /tasks or check_task` });
617
+ // Plan 20 (Plan 18's settle half): tick whatever plan item this task was bound to, from the
618
+ // task's REAL outcome — completed→done, blocked→blocked, else failed; cancelled counts as failed
619
+ // (the work did not happen). Then refresh the pinned plan panel from disk. Without this the item
620
+ // stayed in_progress forever and boot's reconcilePlans wrongly marked it interrupted.
621
+ settlePlanItem(taskId, wasCancelled ? "failed" : res.trace.outcome === "completed" ? "done" : res.trace.outcome === "blocked" ? "blocked" : "failed",
622
+ wasCancelled ? "task cancelled" : `task ${taskId} ${res.trace.outcome}`);
623
+ if (res.trace.artifacts.length > 0 || res.trace.outputArtifacts.length > 0) bumpBrowserForArtifacts();
624
+ };
625
+ const failTask = (taskId: string, agentId: string, e: unknown) => {
626
+ setTaskFields(props.ws, props.db, taskId, { status: "failed", stepStatus: "failed", summary: e instanceof Error ? e.message : String(e) });
627
+ say({ kind: "system", text: ` ⚠ background task ${taskId} (${agentId}) failed — /tasks` });
628
+ settlePlanItem(taskId, "failed", e instanceof Error ? e.message : String(e));
629
+ };
630
+ // Shared by both settle paths: settle the bound item (no-op when the task bound none) and push the
631
+ // refreshed fold to the plan panel so the checkbox flips on screen, not just on disk.
632
+ const settlePlanItem = (taskId: string, status: "done" | "failed" | "blocked", note: string) => {
633
+ const settled = settlePlanItemForTask(props.ws, props.db, taskId, status, note);
634
+ if (settled) { const st = foldPlan(props.ws, settled.planId); if (st) setPlan(st); }
635
+ };
636
+ // Plan 21 §2: a background settle that PRODUCED artifacts, while the browser is open. Its run is
637
+ // structurally invisible to scopes 1–2 (no delegatedOut edge, no ledger turn), so injecting a row
638
+ // there would lie — the bump re-gathers the shelf's data (a real change only in all-runs) and
639
+ // scopes 1–2 get a one-keystroke hint. Set even while READING: the hint renders only on the shelf's
640
+ // mode line, so it simply surfaces on esc (dropping it there lost the signal permanently). Settles
641
+ // with NO artifacts (failed, cancelled, empty) never hint — the mode line must not promise a row
642
+ // that does not exist in any scope.
643
+ const bumpBrowserForArtifacts = () => {
644
+ setBrowser((b) => {
645
+ if (!b) return b;
646
+ const hint = b.ui.scope === "all" ? undefined : "+ new from background — press 3";
647
+ return { ...b, bump: b.bump + 1, ui: { ...b.ui, hint } };
648
+ });
649
+ };
650
+
651
+ // Fire-and-forget a goal onto another agent (dispatch_task). Returns the taskId immediately; the
652
+ // scheduler starts it when the agent is under its maxConcurrentRuns cap (else it stays queued).
653
+ const dispatch: NonNullable<RunDeps["dispatch"]> = (o) => {
654
+ const activeModel = model;
655
+ if (!activeModel) return { error: "no model configured" };
656
+ // Global ceiling (fix): each detached dispatch is an independent request for BUDGETS (depth +
657
+ // runCounter reset), so per-request caps can't bound a dispatch CHAIN. Refuse over-ceiling BEFORE
658
+ // creating the task record so a runaway fan-out is bounded and no orphan record is left behind.
659
+ if (scheduler.atCapacity()) {
660
+ const msg = `background run ceiling reached (${scheduler.inFlight()}/${scheduler.ceiling} in flight + queued) — await or cancel a task before dispatching more`;
661
+ say({ kind: "system", text: ` ⊘ dispatch refused: ${msg}` });
662
+ return { error: msg };
663
+ }
664
+ const taskId = mkTaskId();
665
+ createBackgroundTask(props.ws, props.db, { taskId, agent: o.agent.id, goal: o.goal });
666
+ say({ kind: "system", text: ` ⇢ dispatched ${taskId} → ${o.agent.id} (background)` });
667
+ const start = () => {
668
+ const controller = new AbortController();
669
+ setTaskFields(props.ws, props.db, taskId, { status: "running", stepStatus: "running" });
670
+ const childDeps = deps(activeModel, { signal: controller.signal });
671
+ const messages: ModelMessage[] = [{ role: "user", content: o.context ? `${o.goal}\n\nContext: ${o.context}` : o.goal }];
672
+ const brief = { from: o.parentAgentId, goal: o.goal, context: o.context, criteria: o.criteria, fromRun: o.parentRunId };
673
+ // ancestry threads the dispatching run's chain + the dispatching agent so the detached run's
674
+ // delegationGuard cycle check (ancestry.includes(to)) spans dispatch hops — an A→B→A ping-pong
675
+ // is refused. Budgets (depth/runCounter) stay reset: a dispatch is still an independent request.
676
+ const promise = executeRun(childDeps, { agent: o.agent, messages, brief, inputArtifacts: o.inputArtifacts, triggeredBy: taskId, ancestry: [...o.parentAncestry, o.parentAgentId] })
677
+ .then((res) => { settleTask(taskId, o.agent.id, res); return res; }, (e) => { failTask(taskId, o.agent.id, e); });
678
+ return { controller, promise };
679
+ };
680
+ // Plan 20 (review finding): a task cancelled while QUEUED never reaches settleTask/failTask —
681
+ // start() never ran — so its bound plan item stayed in_progress forever. The scheduler fires
682
+ // onCancelQueued for exactly this drop-from-queue transition; settle the item there.
683
+ scheduler.submit({
684
+ taskId, agentId: o.agent.id, cap: o.agent.budgets.maxConcurrentRuns, start,
685
+ onCancelQueued: () => settlePlanItem(taskId, "failed", "task cancelled while queued"),
686
+ });
687
+ return { taskId };
688
+ };
689
+
690
+ // Block until a background task settles (bounded). Awaits the scheduler promise when it's running,
691
+ // else polls the persisted record (covers already-settled and still-queued tasks). awaiterAgentId
692
+ // (stamped by run.ts) is the agent whose run is calling await_task — used to detect a self-block.
693
+ const awaitTask = async (taskId: string, timeoutMs = 120_000, awaiterAgentId?: string): Promise<TaskAwaitResult> => {
694
+ const existing = readTaskState(props.ws, taskId);
695
+ if (!existing) return { status: "unknown", error: `no task "${taskId}"` };
696
+ if (TERMINAL_TASK_STATUS.has(existing.status)) return taskSummary(taskId);
697
+ const running = scheduler.awaitRunning(taskId);
698
+ if (running) {
699
+ await raceWithTimeout(running, timeoutMs); // clears its timer on either outcome (no leaked timeout)
700
+ } else {
701
+ // Not running — it's QUEUED (a task only sits queued because its agent is at its concurrency
702
+ // cap). If the awaiting run is itself a run of that SAME agent, it holds one of those slots and
703
+ // can never free it while parked here: awaiting would deadlock until the timeout. Fail fast.
704
+ if (existing.status === "queued" && awaiterAgentId && existing.agent === awaiterAgentId) {
705
+ return { status: "unavailable", error: `await_task would deadlock: ${taskId} is queued behind ${awaiterAgentId}'s own concurrency slot (this run holds it). Raise maxConcurrentRuns, or await it after this run frees its slot.` };
706
+ }
707
+ const start = Date.now();
708
+ while (Date.now() - start < timeoutMs) {
709
+ const t = readTaskState(props.ws, taskId);
710
+ if (t && TERMINAL_TASK_STATUS.has(t.status)) break;
711
+ await sleep(50);
712
+ }
713
+ }
714
+ return taskSummary(taskId);
715
+ };
716
+
717
+ const deps = (model: Model, over?: { signal?: AbortSignal }) => makeDeps({
718
+ ws: props.ws, db: props.db, model,
719
+ requestApproval,
720
+ onStep: (ev) => {
721
+ const { phase, tool, agent, delta, note, argsPreview } = ev;
722
+ // Plan 18: a plan snapshot. Phase-less by construction, so statusReducer never sees it and the live
723
+ // status map cannot be corrupted by a "plan" AgentState that does not exist. Handled exactly the
724
+ // way `note` is: an early return, before any phase branch.
725
+ if (ev.plan) { setPlan(ev.plan); return; }
726
+ // Plan 10: fold every phase-tagged event into the live status map (drives the StatusBar) and
727
+ // the per-run pane feed (drives SquadPanes). Both read the one event stream; nothing invented.
728
+ if (phase && ev.runId) {
729
+ const now = Date.now();
730
+ statusRef.current = statusReducer(statusRef.current, ev, now);
731
+ setStatuses(statusList(statusRef.current));
732
+ // The pane feed carries tool activity only (the streamed/final reply stays in the scrollback
733
+ // reply channel — duplicating it in the pane raced that channel; see SquadPanes PaneEntry).
734
+ if (phase === "tool_start" && tool)
735
+ bumpPaneFeed(ev.runId, (e) => ({ lines: [...e.lines, `→ ${tool}${argsPreview ? ` ${argsPreview}` : ""}`].slice(-PANE_FEED_LINES) }));
736
+ // Plan 13 (corrected): accumulate block data for ALL runs. The consistent block view shows
737
+ // every agent (root and sub-agents) as a fixed-height block. The full text is still recorded
738
+ // on disk — this bounds the SCREEN, not the record.
739
+ if (phase === "delta") {
740
+ const d = ev.text ?? delta ?? "";
741
+ if (d) {
742
+ // Determine depth and parent for nesting (root = 0, children = 1+)
743
+ const isRoot = ev.runId === foregroundRootRef.current;
744
+ const depth = isRoot ? 0 : 1; // simplified: root at 0, all children at 1
745
+ bumpBlockFeed(ev.runId, d, agent, undefined, depth);
746
+ }
747
+ }
748
+ }
749
+ if (delta) {
750
+ // Plan 13 (corrected): ONLY accumulate root's direct reply AND foreground @agent turns in
751
+ // streamRef (for scrollback). Background dispatched runs only go to the block feed — the
752
+ // block is their only on-screen presence. Check by: (1) agent is root, OR (2) run is
753
+ // foreground (triggeredBy === "user"), OR (3) no runId (legacy path).
754
+ const runEntry = ev.runId ? activeRuns.current.get(ev.runId) : undefined;
755
+ const isForegroundRun = !runEntry || runEntry.triggeredBy === "user";
756
+ const isRootReply = agent === "root" || isForegroundRun;
757
+ if (isRootReply) {
758
+ streamedRef.current = true; streamFromRef.current = agent; streamRef.current += delta;
759
+ streamRunIdRef.current = ev.runId;
760
+ const { blocks } = splitCompletedBlocks(streamRef.current);
761
+ if (blocks.length > streamBlocksRef.current) {
762
+ for (const b of blocks.slice(streamBlocksRef.current)) say({ kind: "agent", from: agent, text: b, rendered: true });
763
+ streamBlocksRef.current = blocks.length;
764
+ }
765
+ }
766
+ return;
767
+ }
768
+ // A run-level breadcrumb (e.g. a delegation verification verdict) — surface it to the captain.
769
+ if (note) { flushStream(); say({ kind: "system", text: ` ${note}` }); return; }
770
+ // The scrollback breadcrumb now fires at real tool_start time (the status bar carries the live
771
+ // role; the ↳ line stays as the record). argsPreview is redacted + capped upstream.
772
+ // Plan 13 (corrected): suppress the breadcrumb for delegate_task — the consistent block
773
+ // replaces it (the block is the delegation's only on-screen presence, not both).
774
+ if (phase === "tool_start" && tool && tool !== "delegate_task") {
775
+ flushStream();
776
+ setActivity(`${agent} → ${tool}()`);
777
+ say({ kind: "system", text: ` ↳ ${agent} → ${tool}()${argsPreview ? " " + argsPreview : ""}` });
778
+ }
779
+ },
780
+ // Per-run steer routing (Phase 4): a run polls only ITS own queued steers.
781
+ pollSteerFor: ({ runId }) => steerRoutes.current.get(runId)?.shift() ?? null,
782
+ signal: over?.signal ?? aborter.current?.signal,
783
+ priceUsd,
784
+ resolveModel,
785
+ configDefaults: props.configDefaults,
786
+ mcp: props.mcp,
787
+ embed: props.embed,
788
+ spendLedger: props.spendLedger, // Plan 09: squad-wide ceilings enforced in the loop, shared by all runs
789
+ telemetry: props.telemetry, // Plan 16: OpenTelemetry export, shared by all runs this session
790
+ dispatch,
791
+ awaitTask,
792
+ // Live-run bookkeeping only. The per-turn AUDIT (ledger user turn + task open) now lives in the
793
+ // engine seam (recordUserTurn in executeRun, guarded by triggeredBy === "user") — Plan 01 Ph5.
794
+ onRunStart: ({ runId, agent, triggeredBy }) => {
795
+ activeRuns.current.set(runId, { agent, triggeredBy });
796
+ if (triggeredBy === "user" && !foregroundRootRef.current) foregroundRootRef.current = runId;
797
+ },
798
+ onRunEnd: ({ runId }) => {
799
+ activeRuns.current.delete(runId);
800
+ steerRoutes.current.delete(runId);
801
+ if (foregroundRootRef.current === runId) foregroundRootRef.current = null;
802
+ },
803
+ });
804
+
805
+ // Is a run part of the FOREGROUND cascade (the watched turn), vs a background dispatched run? Walk
806
+ // its triggeredBy chain: a foreground root is triggeredBy "user" (and is foregroundRootRef); a
807
+ // background root is triggeredBy a `task_*` id. A descendant inherits its root's nature via the
808
+ // chain of parent runIds still in activeRuns.
809
+ const isForegroundRun = (runId: string): boolean => {
810
+ let cur: string | undefined = runId;
811
+ const seen = new Set<string>();
812
+ while (cur && !seen.has(cur)) {
813
+ seen.add(cur);
814
+ if (cur === foregroundRootRef.current) return true;
815
+ const entry = activeRuns.current.get(cur);
816
+ if (!entry) return false; // parent already settled — can't confirm foreground
817
+ if (entry.triggeredBy === "user") return true; // reached a foreground root
818
+ if (entry.triggeredBy.startsWith("task_")) return false; // reached a background dispatch root
819
+ cur = entry.triggeredBy; // walk up via the parent runId
820
+ }
821
+ return false;
822
+ };
823
+
824
+ // Route a steer while a run is in flight: `@agent …` → that agent's active run; plain text → the
825
+ // watched (foreground) root run, falling back to any single active run.
826
+ const routeSteer = (value: string) => {
827
+ const push = (runId: string, text: string) => {
828
+ const q = steerRoutes.current.get(runId) ?? [];
829
+ q.push(text);
830
+ steerRoutes.current.set(runId, q);
831
+ };
832
+ const parsed = parseInput(value);
833
+ if (parsed.kind === "address") {
834
+ // The same agent can be live in TWO runs (a foreground cascade AND a background dispatch). Prefer
835
+ // the foreground-cascade run so an @agent correction lands on the turn the captain is watching,
836
+ // not a random background run picked by Map insertion order. Fall back to the first match.
837
+ const matches = [...activeRuns.current.entries()].filter(([, v]) => v.agent === parsed.to);
838
+ if (!matches.length) { say({ kind: "system", text: ` no active run for @${parsed.to} to steer` }); return; }
839
+ const hit = matches.find(([id]) => isForegroundRun(id)) ?? matches[0];
840
+ push(hit[0], parsed.text);
841
+ say({ kind: "user", text: `(steer @${parsed.to}) ${parsed.text}` });
842
+ return;
843
+ }
844
+ const target = foregroundRootRef.current ?? [...activeRuns.current.keys()][0];
845
+ if (!target) { say({ kind: "system", text: " nothing running to steer" }); return; }
846
+ push(target, parsed.kind === "chat" ? parsed.text : value);
847
+ say({ kind: "user", text: `(steer) ${value}` });
848
+ };
849
+
850
+ const submit = async (value: string) => {
851
+ if (!value.trim()) return;
852
+ // Typing `exit` or `quit` closes taicho — the same graceful shutdown as Esc / Ctrl+C. Checked before
853
+ // steer/history so it fires even mid-run and never clutters the ↑/↓ recall.
854
+ if (/^(exit|quit)$/i.test(value.trim())) { setInput(""); quit(); return; }
855
+ // Plan 24: every sent line joins the ↑/↓ history (session + the persisted per-workspace file).
856
+ setHistory((h) => pushHistory(h, value));
857
+ appendHistory(props.ws, value);
858
+
859
+ if (busy) { setInput(""); routeSteer(value); return; }
860
+
861
+ const matches = suggestCommands(value);
862
+ if (matches.length > 0) { acceptSuggestion(matches); return; } // Enter selects the highlighted command
863
+ setInput("");
864
+
865
+ const parsed = parseInput(value);
866
+ say({ kind: "user", text: value });
867
+
868
+ // Slash commands work even without a model (e.g. /login to acquire one).
869
+ if (parsed.kind === "slash") return runSlash(parsed.cmd, parsed.arg);
870
+
871
+ if (!model) { say({ kind: "system", text: "No credentials — set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY and relaunch, or run /login openai. I won't burn tokens until then." }); return; }
872
+ const activeModel = model;
873
+
874
+ setBusy(true);
875
+ setActivity(parsed.kind === "address" ? `${parsed.to} · thinking…` : "root · thinking…");
876
+ foregroundRootRef.current = null; // the next user-triggered onRunStart claims this turn's root
877
+ aborter.current = new AbortController();
878
+ streamRef.current = ""; streamFromRef.current = ""; streamedRef.current = false; streamBlocksRef.current = 0; streamRunIdRef.current = undefined;
879
+ clearStatuses();
880
+ clearPaneFeed();
881
+ clearBlockFeed();
882
+ try {
883
+ if (parsed.kind === "chat") {
884
+ // In-memory conversation for THIS session. The durable audit (ledger + task) and the derived
885
+ // boot-replay cache (thread.jsonl, compacted per Plan 05 Ph3) are written by the engine seam.
886
+ thread.current.push({ role: "user", content: parsed.text });
887
+ const root = await loadAgent(props.ws, "root");
888
+ const res = await executeRun(deps(activeModel), { agent: root, messages: [...thread.current], triggeredBy: "user" });
889
+ flushStream(); // commit the final streamed turn; only fall back to res.text if nothing streamed
890
+ if (!streamedRef.current) say({ kind: "agent", from: "root", text: res.text, rendered: true });
891
+ if (res.trace.outcome === "completed") {
892
+ thread.current.push({ role: "assistant", content: res.text });
893
+ // Plan 21: the run ends INSIDE the browser — dock it when the turn produced artifacts.
894
+ lastRunRef.current = res.runId;
895
+ const artifacts = gatherConversationArtifacts(props.ws, res.runId);
896
+ if (artifacts.length > 0) dockBrowser({ rootRunId: res.runId, ui: initialBrowserUi(), bump: 0 });
897
+ } else {
898
+ thread.current.pop(); // drop the user turn so failures don't accumulate as context
899
+ maybeSayAuthExpired(res.text);
900
+ say({ kind: "system", text: ` run: ${res.runId} (${res.trace.outcome}, ${res.trace.tokens} tok, ${res.trace.costUsd == null ? "subscription" : "$" + res.trace.costUsd.toFixed(4)})` });
901
+ }
902
+ setRoster(loadIndex(props.db)); // create_agent may have grown the squad
903
+ } else {
904
+ const target = await loadAgent(props.ws, parsed.to).catch(() => null);
905
+ if (!target) { say({ kind: "system", text: `No agent "${parsed.to}". Try /agents, or describe one to root.` }); return; }
906
+ const res = await executeRun(deps(activeModel), { agent: target, messages: [{ role: "user", content: parsed.text }], triggeredBy: "user" });
907
+ flushStream();
908
+ if (!streamedRef.current) say({ kind: "agent", from: target.id, text: res.text, rendered: true });
909
+ if (res.trace.outcome === "failed") maybeSayAuthExpired(res.text);
910
+ say({ kind: "system", text: ` run: ${res.runId} (${res.trace.outcome}, ${res.trace.tokens} tok, ${res.trace.costUsd == null ? "subscription" : "$" + res.trace.costUsd.toFixed(4)}, ${res.trace.artifacts.length} artifact(s))` });
911
+ // Plan 21: dock the browser — like the chat path, gated on a COMPLETED run (the old bar's
912
+ // unconditional @agent gather was accidental; a failed turn never auto-enters — spec §1).
913
+ if (res.trace.outcome === "completed") {
914
+ lastRunRef.current = res.runId;
915
+ const artifacts = gatherConversationArtifacts(props.ws, res.runId);
916
+ if (artifacts.length > 0) dockBrowser({ rootRunId: res.runId, ui: initialBrowserUi(), bump: 0 });
917
+ }
918
+ }
919
+ } catch (e) {
920
+ // A pre-run failure that throws rather than returning a failed RunResult — e.g. resolveModel's
921
+ // explicit-model guard for a misconfigured OpenRouter agent. Surface it instead of crashing Ink.
922
+ say({ kind: "system", text: ` ${e instanceof Error ? e.message : String(e)}` });
923
+ } finally { setBusy(false); clearStatuses(); clearBlockFeed(); }
924
+ };
925
+
926
+ // Plan 21: GC for the browser's `g` verb — protect a version by what CONSUMES it, never by its own
927
+ // producing run's record (folding trace.artifacts in would pin every version ever produced and
928
+ // shadow keep-latest-N). dryRun previews on the SAME code path the confirm then runs.
929
+ const gcRun = (dryRun: boolean) =>
930
+ gcArtifacts(props.ws, {
931
+ referenced: collectReferencedArtifacts({
932
+ traces: listTraces(props.ws),
933
+ taskResultRefs: listTaskIndex(props.db).map((t) => t.result_ref),
934
+ }),
935
+ dryRun,
936
+ });
937
+
938
+ const runSlash = async (cmd: string, arg: string) => {
939
+ // Plan 20: mid-session roster reindex, for hand-edits to agents/*/agent.md (e.g. `team: news`).
940
+ // Boot also reindexes unconditionally; this covers edits made while the REPL is open. The bare
941
+ // `/agents` list stays in runSlashPure.
942
+ if (cmd === "agents" && arg.trim().toLowerCase() === "reindex") {
943
+ await reindex(props.ws, props.db);
944
+ setRoster(loadIndex(props.db)); // review finding: /teach + approvePolicy read this state, not the DB
945
+ say({ kind: "system", text: " roster reindexed from agents/*/agent.md" });
946
+ return;
947
+ }
948
+ if (cmd === "clear") {
949
+ // Clear the conversation: (1) wipe the terminal incl. native scrollback — a <Static> line can't be
950
+ // un-written; (2) forget the in-memory context; (3) durably reset root's persisted conversation
951
+ // (ledger archived, replay cache wiped) so it won't rehydrate on the next turn or boot.
952
+ stdout.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback + cursor home
953
+ thread.current = [];
954
+ const archived = clearConversation(props.ws, "root");
955
+ setLines([{ kind: "system", text: ` conversation cleared — fresh start${archived ? " (prior history archived)" : ""}.` }]);
956
+ setClearEpoch((n) => n + 1); // remount <Static> so it re-emits from the top over the wiped screen
957
+ return;
958
+ }
959
+ // Plan 22: /teams and bare /agents open the Org browser — the dedicated team/agent management mode.
960
+ if (cmd === "teams") { dockOrg("teams"); return; }
961
+ if (cmd === "agents" && !arg.trim()) { dockOrg("agents"); return; }
962
+ if (cmd === "workflows") { dockWorkflows(); return; }
963
+ if (cmd === "view") {
964
+ const mode = arg.trim().toLowerCase();
965
+ if (!mode) { say({ kind: "system", text: ` live view: ${viewMode} (usage: /view ${VIEW_MODES.join("|")})` }); return; }
966
+ if (!isViewMode(mode)) { say({ kind: "system", text: ` unknown view "${mode}" — try /view ${VIEW_MODES.join("|")}` }); return; }
967
+ setViewMode(mode);
968
+ persistViewMode(props.ws, mode);
969
+ say({ kind: "system", text: ` live view → ${mode}` });
970
+ return;
971
+ }
972
+ if (cmd === "plan") {
973
+ const a = arg.trim().toLowerCase();
974
+ if (a && a !== "on" && a !== "off") { say({ kind: "system", text: ' usage: /plan [on|off]' }); return; }
975
+ const next = a ? a === "on" : !planPanelOn;
976
+ setPlanPanelOn(next);
977
+ persistPlanPanel(props.ws, next);
978
+ const summary = plan ? ` (${plan.handle}: ${plan.counts.done}/${plan.counts.total} done)` : " (no live plan)";
979
+ say({ kind: "system", text: ` plan panel → ${next ? "on" : "off"}${next ? summary : ""}` });
980
+ return;
981
+ }
982
+ if (cmd === "status") { say({ kind: "system", text: ` ${formatAuthStatus(authSource)}` }); return; }
983
+ if (cmd === "login") {
984
+ if (arg && arg !== "openai") { say({ kind: "system", text: ` unknown login target: ${arg} (try /login openai)` }); return; }
985
+ setBusy(true);
986
+ setActivity("signing in…");
987
+ say({ kind: "system", text: " opening browser…" });
988
+ try {
989
+ const src = await props.onLogin();
990
+ const built = props.buildFromAuth(src);
991
+ // Re-arm the live model/resolver/pricer state, then flip authSource. The NEXT submit reads
992
+ // these via deps(), so the REPL is usable without restart.
993
+ setModel(built.model);
994
+ setResolveModel(() => built.resolveModel);
995
+ setPriceUsd(() => built.priceUsd);
996
+ setAuthSource(src);
997
+ say({ kind: "system", text: " signed in with ChatGPT — ready." });
998
+ } catch (e) {
999
+ const msg = e instanceof Error ? e.message : String(e);
1000
+ say({ kind: "system", text: ` login failed: ${msg}` });
1001
+ } finally { setBusy(false); }
1002
+ return;
1003
+ }
1004
+ if (cmd === "logout") {
1005
+ if (arg && arg !== "openai") { say({ kind: "system", text: ` unknown logout target: ${arg} (try /logout openai)` }); return; }
1006
+ props.onLogout();
1007
+ setModel(null);
1008
+ setResolveModel(() => undefined);
1009
+ setPriceUsd(() => undefined);
1010
+ setAuthSource({ kind: "none" });
1011
+ say({ kind: "system", text: " logged out of openai." });
1012
+ return;
1013
+ }
1014
+ if (cmd === "mcp") {
1015
+ if (!props.mcp) { say({ kind: "system", text: " MCP is disabled (set mcp.enabled in taicho.yaml or add a server)." }); return; }
1016
+ const mcp = props.mcp;
1017
+ const parsed = parseMcpCommand(arg);
1018
+ if (parsed.kind === "error") { say({ kind: "system", text: ` ${parsed.message}` }); return; }
1019
+ if (parsed.kind === "list") { formatMcpStatus(mcp.list()).forEach((t) => say({ kind: "system", text: t })); return; }
1020
+ const inYaml = (props.mcpYamlServers ?? []).includes(parsed.name);
1021
+ if (parsed.kind === "remove") {
1022
+ const inStore = removeMcpServer(props.ws, parsed.name);
1023
+ const live = await mcp.removeServer(parsed.name);
1024
+ if (inYaml) say({ kind: "system", text: ` "${parsed.name}" is defined in taicho.yaml — dropped for this session; edit the file to remove it permanently.` });
1025
+ else say({ kind: "system", text: inStore || live ? ` removed "${parsed.name}".` : ` no such MCP server "${parsed.name}".` });
1026
+ return;
1027
+ }
1028
+ if (parsed.kind === "add" && inYaml) { say({ kind: "system", text: ` "${parsed.name}" is already defined in taicho.yaml — edit the file, or add it under a different name.` }); return; }
1029
+ // add | login | reconnect — all may connect (and open a browser for OAuth).
1030
+ setBusy(true);
1031
+ const verb = parsed.kind === "add" ? "connecting" : parsed.kind === "login" ? "signing in to" : "reconnecting";
1032
+ setActivity(`${verb} ${parsed.name}…`);
1033
+ say({ kind: "system", text: ` ${verb} "${parsed.name}"…` });
1034
+ try {
1035
+ let st;
1036
+ if (parsed.kind === "add") { addMcpServer(props.ws, parsed.name, parsed.spec); st = await mcp.addServer(parsed.name, parsed.spec); }
1037
+ else if (parsed.kind === "login") st = await mcp.login(parsed.name);
1038
+ else st = await mcp.reconnect(parsed.name);
1039
+ formatMcpStatus([st]).forEach((t) => say({ kind: "system", text: t }));
1040
+ if (st.status === "connected" && parsed.kind === "add") say({ kind: "system", text: ` add "mcp:${parsed.name}" to an agent's tools to let it use these.` });
1041
+ } catch (e) {
1042
+ say({ kind: "system", text: ` ${parsed.kind} failed: ${e instanceof Error ? e.message : String(e)}` });
1043
+ } finally { setBusy(false); }
1044
+ return;
1045
+ }
1046
+ if (cmd === "teach") {
1047
+ const spaceIdx = arg.indexOf(" ");
1048
+ const agentId = spaceIdx === -1 ? arg : arg.slice(0, spaceIdx);
1049
+ const correction = spaceIdx === -1 ? "" : arg.slice(spaceIdx + 1).trim();
1050
+ if (!agentId || !correction) { say({ kind: "system", text: " usage: /teach <agentId> <correction>" }); return; }
1051
+ if (!roster.some((r) => r.id === agentId)) { say({ kind: "system", text: `No agent "${agentId}". Try /agents.` }); return; }
1052
+ if (!model) { say({ kind: "system", text: " no model — set credentials first" }); return; }
1053
+ const activeModel = model;
1054
+ setBusy(true);
1055
+ setActivity(`teaching ${agentId}…`);
1056
+ try {
1057
+ // Plan 07: route the distiller through the Codex-safe streaming shape when signed in with a
1058
+ // ChatGPT subscription (a bare non-streaming call 400s). Plan 09: meter it against the squad
1059
+ // ceiling (real model call, but no run trace ⇒ not surfaced in /costs — see coaching/teach.ts).
1060
+ const draft = await draftPolicy(activeModel, agentId, correction, {
1061
+ codexBackend: authSource.kind === "oauth-openai-codex",
1062
+ spendLedger: props.spendLedger,
1063
+ priceUsd,
1064
+ });
1065
+ const decision = await requestApproval({ kind: "propose_coaching", draft });
1066
+ if (decision.type === "reject") { say({ kind: "system", text: " discarded" }); }
1067
+ else {
1068
+ const finalDraft = decision.type === "edit" ? mergeDraft(draft, decision.draft) : draft;
1069
+ persistApprovedPolicy(props.ws, finalDraft, agentId);
1070
+ say({ kind: "system", text: ` taught ${agentId}: ${finalDraft.do}` });
1071
+ }
1072
+ } catch (e) {
1073
+ say({ kind: "system", text: ` teach error: ${e instanceof Error ? e.message : String(e)}` });
1074
+ } finally { setBusy(false); }
1075
+ return;
1076
+ }
1077
+ if (cmd === "kb") {
1078
+ const parsed = parseKbCommand(arg);
1079
+ if (parsed.kind === "error") { say({ kind: "system", text: ` ${parsed.message}` }); return; }
1080
+ if (parsed.kind === "list") {
1081
+ const rows = listNodeRows(props.db, parsed.filter);
1082
+ if (!rows.length) { say({ kind: "system", text: " (no matching nodes)" }); return; }
1083
+ rows.forEach((r) => say({ kind: "system", text: ` [${r.id}] (${r.kind}) ${r.title} · ${r.source ?? "—"}` }));
1084
+ return;
1085
+ }
1086
+ if (parsed.kind === "forget") {
1087
+ const r = forgetNodes(props.ws, props.db, parsed.filter);
1088
+ say({ kind: "system", text: ` forgot ${r.removedNodes} node(s), ${r.removedEdges} edge(s)` });
1089
+ return;
1090
+ }
1091
+ if (parsed.kind === "reindex") {
1092
+ reindexKnowledge(props.ws, props.db);
1093
+ const embedded = props.embed ? await reembedAll(props.db, props.embed) : 0;
1094
+ say({ kind: "system", text: ` reindexed from files; re-embedded ${embedded} node(s)` });
1095
+ return;
1096
+ }
1097
+ // sync — drives the librarian per changed doc through the run pipeline
1098
+ if (!model) { say({ kind: "system", text: " /kb sync needs a model — set a key or /login openai." }); return; }
1099
+ const activeModel = model;
1100
+ setBusy(true);
1101
+ setActivity("librarian · syncing…");
1102
+ try {
1103
+ const ingest = async (path: string, hash: string) => {
1104
+ const librarian = await loadAgent(props.ws, LIBRARIAN_ID);
1105
+ await executeRun(deps(activeModel), {
1106
+ agent: librarian,
1107
+ messages: [{ role: "user", content: `Ingest the source document "${path}". Read it with read_source, extract the entities and relationships it asserts, and remember each with typed edges.` }],
1108
+ triggeredBy: "user",
1109
+ ingestSource: `${path}@${hash}`,
1110
+ });
1111
+ };
1112
+ const s = await syncKnowledgeSources({ ws: props.ws, db: props.db, ingest });
1113
+ say({ kind: "system", text: ` sync: ${s.changedDocs} doc(s) ingested, ${s.deletedDocs} removed, ${s.removedNodes} old node(s) cleared` });
1114
+ } catch (e) {
1115
+ say({ kind: "system", text: ` sync failed: ${e instanceof Error ? e.message : String(e)}` });
1116
+ } finally { setBusy(false); }
1117
+ return;
1118
+ }
1119
+ if (cmd === "skills") {
1120
+ const parsed = parseSkillCommand(arg);
1121
+ if (parsed.kind === "error") { say({ kind: "system", text: ` ${parsed.message}` }); return; }
1122
+ if (parsed.kind === "list") {
1123
+ const skills = listSkills(props.ws);
1124
+ if (!skills.length) { say({ kind: "system", text: " (no skills)" }); return; }
1125
+ skills.forEach((s) => say({ kind: "system", text: ` [${s.id}] ${s.name} (${s.status}) — ${s.description}` }));
1126
+ return;
1127
+ }
1128
+ if (parsed.kind === "show") {
1129
+ const all = listSkills(props.ws);
1130
+ const s = all.find((x) => x.name === parsed.arg) ?? readSkill(props.ws, parsed.arg);
1131
+ if (!s) { say({ kind: "system", text: ` no skill "${parsed.arg}"` }); return; }
1132
+ say({ kind: "system", text: ` [${s.id}] ${s.name} (${s.status}) — ${s.description}` });
1133
+ s.body.split("\n").forEach((ln) => say({ kind: "system", text: ` ${ln}` }));
1134
+ return;
1135
+ }
1136
+ if (parsed.kind === "remove") {
1137
+ say({ kind: "system", text: deleteSkill(props.ws, props.db, parsed.id) ? ` removed ${parsed.id}` : ` no skill "${parsed.id}"` });
1138
+ return;
1139
+ }
1140
+ // reindex
1141
+ reindexSkills(props.ws, props.db);
1142
+ say({ kind: "system", text: ` reindexed ${listSkills(props.ws).length} skill(s) from files` });
1143
+ return;
1144
+ }
1145
+ if (cmd === "artifacts") {
1146
+ // Plan 21 Ph4: /artifacts IS the browser. The five subcommands retired — every verb lives on a
1147
+ // key next to the thing it acts on (list→shelf, show→⏎ reader, annotate→a, approve→y, gc→g).
1148
+ if (arg.trim()) {
1149
+ say({ kind: "system", text: " the browser owns this now — /artifacts opens it (⏎ read · a annotate · y approve · g gc in all-runs)" });
1150
+ return;
1151
+ }
1152
+ const rootRunId = lastRunRef.current ?? latestRunFallback(props.ws);
1153
+ dockBrowser({ rootRunId, ui: { ...initialBrowserUi(), scope: rootRunId ? "run" : "all" }, bump: 0 });
1154
+ return;
1155
+ }
1156
+ if (cmd === "tasks") {
1157
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
1158
+ if (parts[0] === "cancel") {
1159
+ const id = parts[1];
1160
+ if (!id) { say({ kind: "system", text: " usage: /tasks cancel <taskId>" }); return; }
1161
+ const rec = cancelTaskState(props.ws, props.db, id);
1162
+ if (!rec) { say({ kind: "system", text: ` no task "${id}"` }); return; }
1163
+ const wasLive = scheduler.cancel(id); // abort if running / drop if queued
1164
+ say({ kind: "system", text: ` ⊗ cancelled ${id}${wasLive ? "" : " (was not running)"}` });
1165
+ return;
1166
+ }
1167
+ const rows = listTaskIndex(props.db, { activeOrBackground: true });
1168
+ if (!rows.length) { say({ kind: "system", text: " (no background tasks)" }); return; }
1169
+ rows.forEach((r) => say({ kind: "system", text: ` [${r.id}] ${r.status} · ${r.agent ?? "?"} · ${r.goal ?? ""}${r.result_ref ? ` → ${r.result_ref}` : ""}` }));
1170
+ return;
1171
+ }
1172
+ if (cmd === "schedules") {
1173
+ const parsed = parseScheduleCommand(tokenize(arg));
1174
+ if (parsed.kind === "error") { say({ kind: "system", text: ` ${parsed.message}` }); return; }
1175
+ if (parsed.kind === "list") {
1176
+ const all = listSchedules(props.ws);
1177
+ if (!all.length) { say({ kind: "system", text: " (no schedules — /schedules add <goal> --every 1h)" }); return; }
1178
+ all.forEach((s) => say({ kind: "system", text: formatScheduleLine(s) }));
1179
+ return;
1180
+ }
1181
+ if (parsed.kind === "add") {
1182
+ try {
1183
+ const s = createSchedule(props.ws, parsed.spec);
1184
+ schedRunner.add(s); // arm it live so it can fire this session
1185
+ say({ kind: "system", text: ` ⏰ added schedule ${s.id} → ${s.agent}: ${s.goal} (${describeTrigger(s.trigger)}, approvals: ${s.approve})` });
1186
+ } catch (e) { say({ kind: "system", text: ` could not add schedule — ${e instanceof Error ? e.message : String(e)}` }); }
1187
+ return;
1188
+ }
1189
+ if (parsed.kind === "remove") {
1190
+ const ok = removeSchedule(props.ws, parsed.id);
1191
+ schedRunner.remove(parsed.id);
1192
+ say({ kind: "system", text: ok ? ` removed schedule ${parsed.id}` : ` no schedule "${parsed.id}"` });
1193
+ return;
1194
+ }
1195
+ // run — fire once now through the runner's inFlight-guarded fireNow (NOT the raw fire closure),
1196
+ // so a manual run SHARES the cadence guard and can't run concurrently with a cadence fire of the
1197
+ // same schedule (the documented "≤1 in-flight per schedule"). fireNow returns false when the id
1198
+ // isn't armed in this session or is already running — report which.
1199
+ if (!readSchedule(props.ws, parsed.id)) { say({ kind: "system", text: ` no schedule "${parsed.id}"` }); return; }
1200
+ if (!schedRunner.fireNow(parsed.id)) {
1201
+ const why = schedRunner.has(parsed.id) ? "already running" : "not armed in this session";
1202
+ say({ kind: "system", text: ` schedule ${parsed.id} — ${why}` });
1203
+ }
1204
+ return;
1205
+ }
1206
+ runSlashPure(cmd, arg, {
1207
+ // Plan 19: read BOTH fresh. `teams` are captain-owned files and `roster` is the derived index of
1208
+ // agent.md, so an edit + reindex shows up without a restart — and, crucially, /teams' member
1209
+ // counts cannot disagree with the team list they are counted against.
1210
+ roster: loadIndex(props.db),
1211
+ teams: listTeams(props.ws).map((t) => ({ id: t.id, charter: t.charter, lead: t.lead })),
1212
+ listTraces: (a?: string) => listTraces(props.ws, a),
1213
+ listPolicies: (a: string) => listPolicies(props.ws, a),
1214
+ deletePolicy: (a: string, p: string) => deletePolicy(props.ws, a, p),
1215
+ // A proposed note's id is all the captain has (from the ⚑ message); find its owning agent by id.
1216
+ approvePolicy: (polId: string) => {
1217
+ for (const r of roster) { const n = approvePolicy(props.ws, r.id, polId); if (n) return n; }
1218
+ return null;
1219
+ },
1220
+ }).forEach(say);
1221
+ };
1222
+
1223
+ // Plan 10: which live surfaces render for the current mode + terminal size (bar-only when small).
1224
+ const layout = resolveLayout(viewMode, termSize.columns, termSize.rows);
1225
+
1226
+ const spacedLines = useMemo(() => annotateSpacing(lines), [lines]);
1227
+ // Static items: the banner + every committed line. Written ONCE to native scrollback and never
1228
+ // repainted — so history can't flicker or snap the viewport, and scroll-up sticks. Requires items to
1229
+ // be append-only + immutable, which `say()` already guarantees (it only ever pushes a Line).
1230
+ const staticItems = useMemo<StaticRow[]>(
1231
+ () => [{ kind: "banner" }, ...spacedLines.map((sp) => ({ kind: "line" as const, sp }))],
1232
+ [spacedLines],
1233
+ );
1234
+
1235
+ return (
1236
+ <>
1237
+ <Static key={clearEpoch} items={staticItems}>
1238
+ {(item, index) =>
1239
+ item.kind === "banner"
1240
+ ? <Text key={index} color="cyan">{BANNER}</Text>
1241
+ : <TranscriptRow key={index} item={item.sp} mdWidth={mdWidth} termCols={termSize.columns} />
1242
+ }
1243
+ </Static>
1244
+ {/* The LIVE region — transient surfaces that repaint. Everything permanent is in <Static> above,
1245
+ so a keystroke or streamed block repaints only this small tail, never the whole history. */}
1246
+ <Box flexDirection="column">
1247
+ {pending && (() => {
1248
+ // key = the head request's stable id: a fresh card (with its own reset useState) mounts per
1249
+ // request, but the visible head never remounts just because another request queued behind it.
1250
+ if (pending.req.kind === "ask_human") {
1251
+ return (
1252
+ <QuestionCard
1253
+ key={pending.id}
1254
+ question={pending.req.question}
1255
+ options={pending.req.options}
1256
+ keyHandlerRef={cardKeyRef}
1257
+ onDecision={answerHead}
1258
+ />
1259
+ );
1260
+ }
1261
+ const view = proposalView(pending.req);
1262
+ return (
1263
+ <ProposalCard
1264
+ key={pending.id}
1265
+ title={view.title}
1266
+ fields={view.fields}
1267
+ keyHandlerRef={cardKeyRef}
1268
+ onDecision={answerHead}
1269
+ />
1270
+ );
1271
+ })()}
1272
+ {/* Plan 10 Phase 4: split panes (one per live agent) sit above the spinner + bar. The mode
1273
+ (bar/panes/both) and terminal size decide what shows; too small ⇒ degrade to bar-only. */}
1274
+ {!operationRunId && !browser && !org && !workflows && layout.showPanes && (
1275
+ <SquadPanes statuses={squadStatuses} feed={paneFeed} columns={termSize.columns} rows={termSize.rows} />
1276
+ )}
1277
+ {/* Plan 13 (corrected): consistent agent blocks — the default squad view. Every agent (root and
1278
+ sub-agents) is rendered as a single block: header + fixed 2-line body. The block IS the record. */}
1279
+ {!operationRunId && !browser && !org && !workflows && allBlocks.length > 0 && (() => {
1280
+ const blockWidth = Math.min(termSize.columns - 4, 96);
1281
+ return (
1282
+ <Box flexDirection="column">
1283
+ {allBlocks.map((b, i) => (
1284
+ <AgentBlock
1285
+ key={b.runId}
1286
+ block={b}
1287
+ focused={focusMode && i === focusIndex}
1288
+ width={blockWidth}
1289
+ now={now}
1290
+ />
1291
+ ))}
1292
+ {focusMode && <Text dimColor> ↑↓ navigate · ⏎ open · esc to close</Text>}
1293
+ </Box>
1294
+ );
1295
+ })()}
1296
+ {/* Plan 13: operation view — drill-in to see the full output of a focused block. */}
1297
+ {operationRunId && (
1298
+ <OperationView
1299
+ ws={props.ws}
1300
+ runId={operationRunId}
1301
+ width={termSize.columns}
1302
+ keyHandlerRef={cardKeyRef}
1303
+ onClose={() => { cardKeyRef.current = null; setOperationRunId(null); }}
1304
+ />
1305
+ )}
1306
+ {/* Plan 21: the artifact browser — docked shelf (or full-screen reader) after a completed
1307
+ artifact-producing turn, or via /artifacts. A pending card SUSPENDS it (gate + key order). */}
1308
+ {!pending && !operationRunId && browser && (
1309
+ <ArtifactBrowser
1310
+ ws={props.ws}
1311
+ width={termSize.columns}
1312
+ rows={termSize.rows}
1313
+ rootRunId={browser.rootRunId}
1314
+ st={browser.ui}
1315
+ bump={browser.bump}
1316
+ onChange={(next) => setBrowser((b) => (b ? { ...b, ui: typeof next === "function" ? next(b.ui) : next } : b))}
1317
+ keyRef={browserKeyRef}
1318
+ gcRun={gcRun}
1319
+ onClose={() => { browserKeyRef.current = null; setBrowser(null); }}
1320
+ onSubmitChat={(text) => { browserKeyRef.current = null; setBrowser(null); void submit(text); }}
1321
+ />
1322
+ )}
1323
+ {/* Plan 22: the Org browser — the team/agent management mode. Suspended by a pending card or the
1324
+ artifact browser (both higher in the dispatch order). */}
1325
+ {!pending && !operationRunId && !browser && org && (
1326
+ <OrgBrowser
1327
+ ws={props.ws}
1328
+ db={props.db}
1329
+ width={termSize.columns}
1330
+ rows={termSize.rows}
1331
+ bump={org.bump}
1332
+ st={org.ui}
1333
+ onChange={(next) => setOrg((o) => (o ? { ...o, ui: typeof next === "function" ? next(o.ui) : next } : o))}
1334
+ keyRef={orgKeyRef}
1335
+ onClose={() => { orgKeyRef.current = null; setOrg(null); }}
1336
+ actions={orgActions}
1337
+ />
1338
+ )}
1339
+ {/* Plan 25: the /workflows browser — read-only inspection, suspended by a card / artifact browser / org. */}
1340
+ {!pending && !operationRunId && !browser && !org && workflows && (
1341
+ <WorkflowBrowser
1342
+ ws={props.ws}
1343
+ width={termSize.columns}
1344
+ bump={workflows.bump}
1345
+ st={workflows.ui}
1346
+ onChange={(next) => setWorkflows((w) => (w ? { ...w, ui: typeof next === "function" ? next(w.ui) : next } : w))}
1347
+ keyRef={workflowsKeyRef}
1348
+ onClose={() => { workflowsKeyRef.current = null; setWorkflows(null); }}
1349
+ />
1350
+ )}
1351
+ {!pending && busy && <RunStatus activity={activity} />}
1352
+ {/* Plan 10: the live status bar, pinned directly above the input (shows during approvals too). */}
1353
+ {planPanelOn && plan && !operationRunId && !browser && !org && !workflows && <PlanPanel plan={plan} width={termSize.columns} />}
1354
+ {layout.showBar && squadStatuses.length > 0 && <StatusBar statuses={squadStatuses} width={termSize.columns} />}
1355
+ {!pending && !operationRunId && !browser && !org && !workflows && (
1356
+ <>
1357
+ <ChatInput
1358
+ value={input}
1359
+ onChange={(v) => { setInput(v); setSelected(0); }}
1360
+ onSubmit={submit}
1361
+ history={history}
1362
+ isActive={!focusMode}
1363
+ suggestOpen={sugg.length > 0}
1364
+ onSuggestNav={(dir) => setSelected((s) => cycleIndex(s, sugg.length, dir))}
1365
+ onSuggestAccept={() => acceptSuggestion(sugg)}
1366
+ placeholder={focusMode ? "(focus mode — esc to return)" : "message root, or / for commands"}
1367
+ width={termSize.columns}
1368
+ dimmed={busy || focusMode}
1369
+ busy={busy}
1370
+ />
1371
+ {sugg.length > 0 && (
1372
+ <Box flexDirection="column">
1373
+ {sugg.map((c, i) => {
1374
+ const on = i === Math.min(selected, sugg.length - 1);
1375
+ return (
1376
+ <Text key={c.name} color={on ? "cyan" : "gray"}>
1377
+ {`${on ? "›" : " "} /${c.name}${c.usage ? " " + c.usage : ""} — ${c.summary}`}
1378
+ </Text>
1379
+ );
1380
+ })}
1381
+ </Box>
1382
+ )}
1383
+ </>
1384
+ )}
1385
+ </Box>
1386
+ </>
1387
+ );
1388
+ }
1389
+
1390
+ function initialLines(p: { model: Model | null; roster: RegistryRow[]; authSource: AuthSource }): Line[] {
1391
+ if (p.authSource.kind === "none")
1392
+ return noCredentialLines().map((text) => ({ kind: "system", text }));
1393
+ if (!p.model)
1394
+ return [
1395
+ { kind: "system", text: "taicho — no API key configured." },
1396
+ { kind: "system", text: "Set ANTHROPIC_API_KEY or OPENAI_API_KEY, then relaunch." },
1397
+ ];
1398
+ if (p.roster.filter((r) => !r.is_root).length === 0)
1399
+ return [
1400
+ { kind: "system", text: "taicho — your squad is empty (root is ready)." },
1401
+ { kind: "system", text: 'Describe your first agent to me (e.g. "I need a researcher that covers geopolitics, with web search"). /agents to list, ESC to quit.' },
1402
+ ];
1403
+ return [{ kind: "system", text: "taicho — squad ready. Bare messages go to root; @agent to address directly; /agents, /costs, /help. ESC to quit." }];
1404
+ }