@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/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # @taicho-ai/cli
2
+
3
+ The Taicho executable and Ink terminal interface. This package composes `@taicho-ai/framework` with
4
+ the standard telemetry package and owns process startup, user interaction, and graceful shutdown.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@taicho-ai/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "taicho": "./src/index.tsx"
7
+ },
8
+ "scripts": {
9
+ "build": "bun scripts/ensure-devtools-stub.ts && bun build src/index.tsx --outdir dist --target bun",
10
+ "test": "bun test src"
11
+ },
12
+ "dependencies": {
13
+ "@inkjs/ui": "^2.0.0",
14
+ "@taicho-ai/contracts": "0.1.0",
15
+ "@taicho-ai/framework": "0.1.0",
16
+ "@taicho-ai/graph": "0.1.0",
17
+ "@taicho-ai/telemetry": "0.1.0",
18
+ "ai": "6.0.199",
19
+ "chalk": "^5.6.2",
20
+ "ink": "^7.1.0",
21
+ "ink-text-input": "^6.0.0",
22
+ "marked": "^12.0.0",
23
+ "marked-terminal": "7.3.0",
24
+ "react": "^19.2.7"
25
+ },
26
+ "devDependencies": {
27
+ "@ai-sdk/provider": "3.0.10",
28
+ "ink-testing-library": "^4.0.0"
29
+ },
30
+ "description": "隊長 (taicho) — a controllable multi-agent squad in your terminal. Ink REPL over @taicho-ai/framework.",
31
+ "license": "MIT",
32
+ "homepage": "https://taicho.ai",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/taicho-ai/taicho.git",
36
+ "directory": "packages/cli"
37
+ },
38
+ "files": [
39
+ "src",
40
+ "!src/**/*.test.ts",
41
+ "!src/**/*.test.tsx"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env bun
2
+ import { render } from "ink";
3
+ import { App } from "./ui/App";
4
+ import { ensureWorkspace } from "@taicho-ai/framework/store/files";
5
+ import { openDb } from "@taicho-ai/framework/store/db";
6
+ import { seedRoot, seedLibrarian, reindex, loadIndex, reconcileWorkerTools } from "@taicho-ai/framework/store/roster";
7
+ import { reindexKnowledge, reconcileKbScope } from "@taicho-ai/framework/store/knowledge";
8
+ import { validateTeams, seedDefaultTeam } from "@taicho-ai/framework/store/teams";
9
+ import { diffSources } from "@taicho-ai/framework/store/sources";
10
+ import { createEmbedder } from "@taicho-ai/framework/core/embed";
11
+ import { ensureEmbedSpace } from "@taicho-ai/framework/store/migrate";
12
+ import { loadConfig, resolveAuth, type AuthSource } from "@taicho-ai/framework/store/config";
13
+ import { buildModel, createModelResolver, type Model } from "@taicho-ai/framework/core/model";
14
+ import { pricerFor } from "@taicho-ai/framework/core/pricing";
15
+ import { readProfile, writeProfile, deleteProfile } from "@taicho-ai/framework/core/auth/profile";
16
+ import { loadThread } from "@taicho-ai/framework/store/thread";
17
+ import { createRefresher } from "@taicho-ai/framework/core/auth/refresh";
18
+ import { runLoginFlow } from "@taicho-ai/framework/core/auth/login";
19
+ import { createCodexProvider } from "@taicho-ai/framework/core/providers/openai-codex";
20
+ import { OPENAI_CODEX_AUTH } from "@taicho-ai/framework/core/auth/constants";
21
+ import { createMcpManager, type McpManager } from "@taicho-ai/framework/core/mcp/manager";
22
+ import { readMcpStore, applyMcpEnv } from "@taicho-ai/framework/store/mcp-store";
23
+ import { makeSpendLedger, hasAnyCeilings } from "@taicho-ai/framework/store/spend-ledger";
24
+ import { seedSkills } from "@taicho-ai/framework/store/seed-skills";
25
+ import { reindexSkills } from "@taicho-ai/framework/store/skills";
26
+ import { reindexTasks, reconcileTasks } from "@taicho-ai/framework/store/task-state";
27
+ import { reindexPlans, reconcilePlans } from "@taicho-ai/framework/store/plans";
28
+ import { reconcileWorkflowRuns, listParkedGates } from "@taicho-ai/graph";
29
+ import { createE2eModel } from "@taicho-ai/framework/core/e2e-model";
30
+ import { parseCli, runHeadless, runTail, scheduleFireOptions } from "@taicho-ai/framework/core/headless";
31
+ import { runScheduleCli } from "@taicho-ai/framework/core/schedule-cli";
32
+ import { runTeamCli } from "@taicho-ai/framework/core/team-cli";
33
+ import { configureLogger, log } from "@taicho-ai/framework/core/logger";
34
+ import { initTelemetry } from "@taicho-ai/telemetry";
35
+
36
+ const ws = process.cwd();
37
+ const cli = parseCli(process.argv);
38
+ // Point the file logger at this workspace and raise the level when --verbose (a general debug mode;
39
+ // historically only the codex path honored TAICHO_DEBUG). All diagnostics now land in taicho.log
40
+ // instead of fighting the Ink render.
41
+ configureLogger({ ws, level: cli.verbose ? "debug" : undefined });
42
+
43
+ // `taicho tail [runId]` only reads the runs/ event stream — short-circuit before the heavy boot
44
+ // (DB, seeds, MCP connect) so an external observer's tail starts instantly.
45
+ if (cli.command?.kind === "tail") {
46
+ await runTail(ws, cli.command);
47
+ process.exit(0);
48
+ }
49
+
50
+ const config = await loadConfig(ws);
51
+ await ensureWorkspace(ws);
52
+
53
+ // `taicho schedule <add|list|remove>` only touches the durable schedule store — short-circuit before
54
+ // the heavy boot (seeds, DB, MCP connect). `schedule run` needs a model, so it falls through to the
55
+ // full boot and is fired via the headless path below.
56
+ if (cli.command?.kind === "schedule" && cli.command.args[0] !== "run") {
57
+ const r = await runScheduleCli({ ws, out: (l) => process.stdout.write(l + "\n") }, cli.command.args);
58
+ process.exit(r.ok ? 0 : 1);
59
+ }
60
+
61
+ // Plan 22: `taicho team <list|add|remove|member>` — captain-owned team management, no model. Opens the
62
+ // DB + reindexes itself, so it short-circuits the heavy boot (seeds beyond root, MCP connect) too.
63
+ if (cli.command?.kind === "team") {
64
+ const r = await runTeamCli({ ws, out: (l) => process.stdout.write(l + "\n") }, cli.command.args);
65
+ process.exit(r.ok ? 0 : 1);
66
+ }
67
+
68
+ await seedRoot(ws, config.defaults);
69
+ await seedLibrarian(ws, config.defaults);
70
+ // Plan 22: the universal `default` team — every agent belongs to it, root leads it, it can't be deleted.
71
+ // Seeded like root/librarian; idempotent, so a customised default.md is left alone.
72
+ seedDefaultTeam(ws);
73
+ // Plan 14 T3: rescue any worker born toolless (`tools: []`) — grant it the default artifact baseline so
74
+ // a live squad (root/2026-07-04-run6's 9 empty-tools agents) becomes usable without hand-editing each file.
75
+ const backfilledWorkers = await reconcileWorkerTools(ws);
76
+ await seedSkills(ws);
77
+ const db = openDb(ws);
78
+ // Plan 20: files are canon, the registry is derived — rebuild EVERY boot (a scan of agents/*/agent.md,
79
+ // trivially cheap) so hand-edits like `team: news` take effect on restart. The old only-if-empty guard
80
+ // left registry.team stale forever (membersOf, team routing, per-team model resolution all read it).
81
+ await reindex(ws, db);
82
+ // Plan 19 Ph1b: rewrite any kb node file still saying `scope: deck` BEFORE the reindex below reads them.
83
+ reconcileKbScope(ws, db);
84
+ reindexKnowledge(ws, db); // rebuild the KB graph index from kb/nodes/*.md (files are canon)
85
+ reindexSkills(ws, db); // rebuild the skills index from skills/*.md (files are canon)
86
+ const kbDrift = diffSources(ws, db);
87
+ // Plan 04 Phase 5: rebuild the task index from files, then reconcile — a task left `running`/`queued`
88
+ // means the process died mid-flight → mark `interrupted` and report it (report-and-ask per Phase 0;
89
+ // auto-resume is deferred). The captain can inspect/cancel via /tasks.
90
+ reindexTasks(ws, db);
91
+ const interruptedTasks = reconcileTasks(ws, db);
92
+ // Plan 18: rebuild the plan index from files, then reconcile — an item left `in_progress` means the
93
+ // process died while its bound run was in flight. Appends `interrupted` (never rewrites the intent),
94
+ // exactly as reconcileTasks does for a task. PENDING items survive a reboot untouched: a plan models
95
+ // intent, not work in flight.
96
+ reindexPlans(ws, db);
97
+ const interruptedItems = reconcilePlans(ws, db);
98
+ // Plan 25: a workflow step left `running` means the process died mid-step — append `interrupted`, never
99
+ // rewriting the definition, exactly as reconcilePlans does for a plan item.
100
+ const interruptedSteps = reconcileWorkflowRuns(ws);
101
+ // Plan 19: a team whose `lead` is missing, or sits on a DIFFERENT team, would route work out of the
102
+ // team it is supposed to run. Report it — one bad team.md must not block boot, and the fix is an edit.
103
+ const teamProblems = validateTeams(ws, db);
104
+ // Plan 19/22: an agent's teams, read from the derived membership index. Injected into the model resolver
105
+ // so it can walk agent → team → defaults without importing the DB. A prepared statement: resolveModel
106
+ // runs once per run, and per delegated child. Many-to-many now, so this returns the ordered team list.
107
+ const teamsOfStmt = db.query<{ team_id: string }, [string]>("SELECT team_id FROM agent_teams WHERE agent_id = ? ORDER BY ord");
108
+ const teamsOf = (agentId: string): string[] => teamsOfStmt.all(agentId).map((r) => r.team_id);
109
+ const notices: string[] = [];
110
+ if (teamProblems.length)
111
+ notices.push(`teams: ${teamProblems.map((p) => `${p.team} (${p.problem})`).join("; ")} — /teams to review`);
112
+ if (kbDrift.changed.length || kbDrift.deleted.length)
113
+ notices.push(`kb: ${kbDrift.changed.length} changed / ${kbDrift.deleted.length} removed source(s) — run /kb sync`);
114
+ if (interruptedItems.length)
115
+ notices.push(`plans: ${interruptedItems.length} item(s) interrupted last session (${interruptedItems.slice(0, 3).map((i) => `${i.planId}/${i.item}`).join(", ")}${interruptedItems.length > 3 ? "…" : ""}) — /plan to review`);
116
+ if (interruptedTasks.length)
117
+ notices.push(`tasks: ${interruptedTasks.length} interrupted last session (${interruptedTasks.slice(0, 3).map((t) => t.taskId).join(", ")}${interruptedTasks.length > 3 ? "…" : ""}) — /tasks to review`);
118
+ if (interruptedSteps.length)
119
+ notices.push(`workflows: ${interruptedSteps.length} step(s) interrupted last session (${interruptedSteps.slice(0, 3).map((s) => `${s.wfId}/${s.step}`).join(", ")}${interruptedSteps.length > 3 ? "…" : ""})`);
120
+ // Plan 25 Ph6: a scheduled/unattended workflow may be parked at a human gate, waiting on the captain.
121
+ const parkedGates = listParkedGates(ws);
122
+ if (parkedGates.length)
123
+ notices.push(`workflows: ${parkedGates.length} run(s) waiting at a human gate (${parkedGates.slice(0, 3).map((p) => `${p.wfId}/${p.gate.step}`).join(", ")}${parkedGates.length > 3 ? "…" : ""}) — ask root to resume`);
124
+ if (backfilledWorkers.length)
125
+ notices.push(`agents: granted the artifact-tool baseline to ${backfilledWorkers.length} worker(s) born toolless (${backfilledWorkers.slice(0, 3).join(", ")}${backfilledWorkers.length > 3 ? "…" : ""})`);
126
+ const startupNotice = notices.length ? notices.join(" · ") : undefined;
127
+ const roster = loadIndex(db);
128
+
129
+ // Semantic KB embedder (optional; null ⇒ keyword+graph recall). Env-driven, decoupled from the chat
130
+ // provider. ensureEmbedSpace wipes stale kb vectors if the embed model/dim changed.
131
+ const embedder = createEmbedder({ provider: config.embeddings?.provider });
132
+ if (embedder) ensureEmbedSpace(db, embedder.model, embedder.dim);
133
+
134
+ // Load every stored MCP server's `env` (secrets saved WITH the server) into process.env BEFORE connecting,
135
+ // so a `${VAR}` ref in a url/header resolves this session exactly as it did when the server was added.
136
+ applyMcpEnv(ws);
137
+ // MCP: connect configured servers (taicho.yaml `mcp.servers` ∪ the /mcp-added store; yaml wins on
138
+ // name collision). Best-effort — a server that fails is skipped, never blocking the REPL. The
139
+ // manager is mutable so /mcp add/remove/login work at runtime. `mcp.enabled: false` disables it.
140
+ const mcp: McpManager | undefined = config.mcp?.enabled === false
141
+ ? undefined
142
+ : await createMcpManager({
143
+ ws,
144
+ // Firecrawl is a built-in default MCP server on every squad (scrape/crawl/search/map/extract)
145
+ // whenever FIRECRAWL_API_KEY is set — lowest precedence, so a workspace's store/yaml can override
146
+ // or replace it. Then layer the /mcp-added store, then taicho.yaml (yaml wins on a name clash).
147
+ servers: {
148
+ ...(process.env.FIRECRAWL_API_KEY
149
+ ? { firecrawl: { command: "npx", args: ["-y", "firecrawl-mcp"], env: { FIRECRAWL_API_KEY: "${FIRECRAWL_API_KEY}" } } }
150
+ : {}),
151
+ ...readMcpStore(ws),
152
+ ...(config.mcp?.servers ?? {}),
153
+ },
154
+ onUrl: (u) => console.error("Open to authorize the MCP server:\n" + u),
155
+ });
156
+ // MCP servers are reaped on shutdown: the ESC quit path awaits closeAll(); SIGTERM is handled by the
157
+ // ONE composed handler registered after telemetry init below (close MCP → flush OTel → exit).
158
+ // (Ctrl+C/SIGINT is owned by Ink, which exits the app; stdio children otherwise die with the
159
+ // foreground process group. Async cleanup can't run in a process "exit" handler, so we don't use one.)
160
+
161
+ // Plan 09: one squad-wide spend ledger, shared by every run this session. DB-backed rolling counters
162
+ // keyed by UTC day / ISO week persist across sessions. Built only when a ceiling is configured, so
163
+ // with no `budgets` in taicho.yaml the loop does zero extra DB work (pre-Plan-09 behavior).
164
+ // Plan 19: the same ledger meters the squad ceiling and every configured team ceiling.
165
+ const ceilingConfig = {
166
+ squad: config.budgets,
167
+ teams: Object.fromEntries(Object.entries(config.teams ?? {}).map(([id, t]) => [id, t.ceilings])),
168
+ };
169
+ const spendLedger = hasAnyCeilings(ceilingConfig) ? makeSpendLedger(db, ceilingConfig) : undefined;
170
+
171
+ // Plan 16: OpenTelemetry. Enabled only when an OTLP endpoint is configured (OTEL_EXPORTER_OTLP_ENDPOINT)
172
+ // — otherwise undefined and every seam skips it (zero overhead). Shared by every run this session. Must
173
+ // be flushed on exit (BatchSpanProcessor buffers), so each exit path below awaits telemetry?.shutdown().
174
+ const telemetry = initTelemetry({ serviceVersion: "0.1.0", logger: log });
175
+
176
+ // Plan 20: ONE composed SIGTERM handler — reap MCP children, flush buffered spans, then EXIT.
177
+ // Previously two independent handlers raced (MCP's exit(0) could beat the un-awaited telemetry
178
+ // flush, dropping spans) and with MCP disabled the telemetry-only handler swallowed the signal
179
+ // without exiting, leaving the REPL running. Registered unconditionally; each step no-ops when its
180
+ // subsystem is off.
181
+ process.on("SIGTERM", () => {
182
+ void (async () => {
183
+ try { await mcp?.closeAll(); } catch { /* best-effort on the way down */ }
184
+ try { await telemetry?.shutdown(); } catch { /* best-effort on the way down */ }
185
+ process.exit(143); // 128+SIGTERM: signal-terminated, not success — what the default disposition reported
186
+ })();
187
+ });
188
+
189
+ const e2eModel = createE2eModel(process.env.TAICHO_E2E_MODEL);
190
+ const authSource = e2eModel
191
+ ? { kind: "env" as const, provider: "openai" as const, model: `e2e:${process.env.TAICHO_E2E_MODEL}` }
192
+ : resolveAuth({ config, loadProfile: () => readProfile() });
193
+
194
+ // Transparency: a signed-in subscription is preferred over env keys; say so when both are present.
195
+ if (authSource.kind === "oauth-openai-codex" && (process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY)) {
196
+ console.error("taicho: using your ChatGPT subscription (an API key is also set — run with TAICHO_PROVIDER=openai to use the key instead).");
197
+ }
198
+
199
+ export interface BuiltAuth {
200
+ model: Model | null;
201
+ resolveModel?: (id: string) => { model: Model; modelId: string; subscription?: boolean; captureCost?: boolean };
202
+ priceUsd?: (u: { inputTokens: number; outputTokens: number }) => number;
203
+ }
204
+
205
+ /** Map an AuthSource -> the model/resolver/pricer the REPL should use. Pure aside from provider
206
+ * construction; called both at boot and after a live /login so the REPL re-arms without restart. */
207
+ function buildFromAuth(src: AuthSource): BuiltAuth {
208
+ if (e2eModel) {
209
+ return {
210
+ model: e2eModel,
211
+ resolveModel: () => ({ model: e2eModel, modelId: `e2e:${process.env.TAICHO_E2E_MODEL}` }),
212
+ priceUsd: () => 0,
213
+ };
214
+ }
215
+ // Plan 12: the per-request transport deadline (ms) for a model fetch — config-disposed, applied to
216
+ // EVERY provider fetch path (env keys + codex). Undefined ⇒ the provider layer's 120s default.
217
+ const modelRequestTimeoutMs = config.defaults?.modelRequestTimeoutMs;
218
+ if (src.kind === "env") {
219
+ // A config-supplied default model is honored for the top-level fallback model too — needed for
220
+ // OpenRouter, whose env-resolved src.model may be empty (it carries no default slug).
221
+ const cfg = { provider: src.provider, model: config.defaults?.model ?? src.model };
222
+ return {
223
+ model: buildModel(cfg, modelRequestTimeoutMs),
224
+ resolveModel: createModelResolver({ config, fallback: cfg, timeoutMs: modelRequestTimeoutMs, teamsOf }).resolveModel,
225
+ priceUsd: pricerFor(cfg.model),
226
+ };
227
+ }
228
+ if (src.kind === "oauth-openai-codex") {
229
+ const codex = createCodexProvider({
230
+ load: () => readProfile(),
231
+ refresh: createRefresher({ load: () => readProfile(), save: writeProfile }),
232
+ timeoutMs: modelRequestTimeoutMs,
233
+ });
234
+ // Subscription calls are not metered in USD; mark subscription:true so the run trace reports
235
+ // "subscription" instead of a (meaningless) dollar cost.
236
+ const pick = (id: string) => {
237
+ // agent → team → defaults, the same walk createModelResolver makes for the env-key providers. With
238
+ // many-to-many membership (Plan 22), the first team carrying a model override wins.
239
+ const t = teamsOf(id).find((tid) => config.teams?.[tid]?.model);
240
+ const m = config.agents?.[id]?.model ?? (t ? config.teams?.[t]?.model : undefined) ?? config.defaults?.model ?? OPENAI_CODEX_AUTH.defaultModelId;
241
+ return { model: codex(m), modelId: m, subscription: true };
242
+ };
243
+ return { model: codex(config.defaults?.model ?? OPENAI_CODEX_AUTH.defaultModelId), resolveModel: pick };
244
+ }
245
+ return { model: null };
246
+ }
247
+
248
+ // buildFromAuth can throw on misconfiguration (e.g. OpenRouter selected with no model) — fail fast
249
+ // with the actionable message rather than an Ink render crash.
250
+ let initial: BuiltAuth;
251
+ try {
252
+ initial = buildFromAuth(authSource);
253
+ } catch (e) {
254
+ console.error(`taicho: ${e instanceof Error ? e.message : String(e)}`);
255
+ process.exit(1);
256
+ }
257
+
258
+ async function onLogin(): Promise<AuthSource> {
259
+ // Never log the token bundle; only print the authorize URL for the paste fallback.
260
+ const profile = await runLoginFlow({ onUrl: (u) => console.error("Open to sign in:\n" + u) });
261
+ writeProfile(profile);
262
+ return { kind: "oauth-openai-codex", accountId: profile.account_id, expiresAt: profile.expires_at };
263
+ }
264
+
265
+ // `taicho run "<goal>"` drives ONE run to completion without Ink, then exits with the run's status.
266
+ // Approvals default to auto-reject (unattended-safe; see core/headless.ts) — override with --approve.
267
+ if (cli.command?.kind === "run") {
268
+ const res = await runHeadless(
269
+ {
270
+ ws, db, model: initial.model,
271
+ resolveModel: initial.resolveModel, priceUsd: initial.priceUsd,
272
+ configDefaults: config.defaults, mcp, embed: embedder?.embed,
273
+ spendLedger, telemetry,
274
+ },
275
+ { goal: cli.command.goal, agent: cli.command.agent, approve: cli.command.approve },
276
+ );
277
+ if (mcp) await mcp.closeAll().catch((e) => log.warn("mcp closeAll failed", e));
278
+ await telemetry?.shutdown(); // flush buffered spans/metrics before exit
279
+ process.exit(res.ok ? 0 : 1);
280
+ }
281
+
282
+ // `taicho schedule run <id>` fires ONE schedule once through the same unattended headless path a live
283
+ // scheduled run uses — the schedule's own approval mode (default reject) applies (no captain, so no
284
+ // unsupervised privileged exec). add/list/remove already exited above; only `run` reaches here.
285
+ if (cli.command?.kind === "schedule") {
286
+ const hd = { ws, db, model: initial.model, resolveModel: initial.resolveModel, priceUsd: initial.priceUsd, configDefaults: config.defaults, mcp, embed: embedder?.embed, spendLedger, telemetry };
287
+ const r = await runScheduleCli(
288
+ // `schedule run` is the same UNATTENDED path a live scheduled fire uses — mark it schedule:<id> so it
289
+ // is EXCLUDED from the target agent's conversation ledger + boot-replay cache (still gets run evidence).
290
+ { ws, out: (l) => process.stdout.write(l + "\n"), fire: (s) => runHeadless(hd, scheduleFireOptions(s)) },
291
+ cli.command.args,
292
+ );
293
+ if (mcp) await mcp.closeAll().catch((e) => log.warn("mcp closeAll failed", e));
294
+ await telemetry?.shutdown(); // flush buffered spans/metrics before exit
295
+ process.exit(r.ok ? 0 : 1);
296
+ }
297
+
298
+ const rootThread = loadThread(ws, "root");
299
+
300
+ render(
301
+ <App
302
+ ws={ws} db={db} roster={roster}
303
+ configDefaults={config.defaults}
304
+ backgroundRunCeiling={config.tasks?.maxBackgroundRuns}
305
+ authSource={authSource}
306
+ buildFromAuth={buildFromAuth}
307
+ onLogin={onLogin}
308
+ onLogout={() => deleteProfile()}
309
+ rootThread={rootThread}
310
+ mcp={mcp}
311
+ mcpYamlServers={Object.keys(config.mcp?.servers ?? {})}
312
+ embed={embedder?.embed}
313
+ spendLedger={spendLedger}
314
+ telemetry={telemetry}
315
+ startupNotice={startupNotice}
316
+ {...initial}
317
+ cfg={authSource.kind === "env" ? { provider: authSource.provider, model: authSource.model } : null}
318
+ />,
319
+ // Plan 24: enable the kitty keyboard protocol so terminals that support it (kitty, Ghostty, WezTerm,
320
+ // iTerm2 3.5+) report Shift+Enter as a DISTINCT sequence (\x1b[13;2u) instead of a bare \r — otherwise a
321
+ // terminal sends the identical byte for Enter and Shift+Enter and no code can tell them apart.
322
+ //
323
+ // mode:"enabled" (NOT "auto"): "auto" queries the terminal (CSI ? u) and only enables on a reply, but
324
+ // Ink sends that query from its constructor and its detector races with its own stdin setup — verified in
325
+ // a real PTY, the reply is MISSED (it even leaks through as a phantom keypress) and the enable sequence
326
+ // \x1b[>1u is never written, so the protocol stays off and Shift+Enter keeps arriving as \r. "enabled"
327
+ // writes \x1b[>1u directly — no query, no race. A terminal that doesn't support it (Terminal.app) simply
328
+ // ignores the unknown control sequence, so this is safe there (Shift+Enter just can't work in Terminal.app).
329
+ //
330
+ // exitOnCtrlC:false because Ink's built-in only recognizes the legacy \x03 byte — under the protocol
331
+ // Ctrl+C arrives as \x1b[99;5u and Ink would never exit. App.tsx owns Ctrl+C instead (it fires for both
332
+ // encodings, since Ink surfaces `input:'c', ctrl:true` either way).
333
+ { exitOnCtrlC: false, kittyKeyboard: { mode: "enabled" } },
334
+ );
@@ -0,0 +1,183 @@
1
+ /** Plan 13 (corrected) — the consistent agent block. An agent (root and every sub-agent) is rendered
2
+ * as a single block: header + fixed 2-line body (3 max). The block NEVER changes shape across its
3
+ * lifecycle — only the state label, rail colour, and body content change:
4
+ *
5
+ * ▎ <name> · <state> · <elapsed> [· <artifact@vN>] ← header (one line)
6
+ * ▎ <body line 1> ← body: fixed 2 lines (3 max)
7
+ * ▎ <body line 2>
8
+ *
9
+ * Variants:
10
+ * - live (amber rail): rolling tail — newest delta line in at bottom, oldest scrolls off inside window
11
+ * - done (green rail): settled summary + artifact handle in header
12
+ * - failed (red rail): failure reason (first 2 lines)
13
+ *
14
+ * The block IS the record: the block you watched live is the exact block that settles into scrollback.
15
+ * No collapse into a different element, no second UI. Display-only: reads the SAME AgentStatus model
16
+ * + delta events the bar/panes/live-trace consume; NEVER feeds back into transcript/ledger/replay. */
17
+ import { useState, useEffect, useRef, type ReactNode } from "react";
18
+ import { Box, Text } from "ink";
19
+ import type { AgentStatus, AgentState } from "@taicho-ai/framework/core/agent-status";
20
+
21
+ export type BlockVariant = "live" | "done" | "failed";
22
+
23
+ export interface AgentBlockData {
24
+ runId: string;
25
+ agent: string;
26
+ state: AgentState;
27
+ since: number;
28
+ waiting: boolean;
29
+ lines: string[];
30
+ variant: BlockVariant;
31
+ artifact?: string;
32
+ summary?: string;
33
+ error?: string;
34
+ parentRunId?: string;
35
+ depth: number;
36
+ }
37
+
38
+ const GLYPH: Record<AgentState, string> = {
39
+ idle: "·", thinking: "…", writing: "✎", working: "●", delegating: "⇢", waiting: "✋",
40
+ };
41
+
42
+ const RULE = "▎";
43
+ const FOCUS_MARKER = "▸";
44
+
45
+ export const BLOCK_BODY_LINES = 2;
46
+ export const BLOCK_BODY_MAX = 3;
47
+
48
+ function railColor(variant: BlockVariant, state: AgentState, waiting: boolean): string {
49
+ if (variant === "failed") return "red";
50
+ if (variant === "done") return "green";
51
+ if (waiting) return "red";
52
+ if (state === "delegating") return "magenta";
53
+ if (state === "writing") return "yellow";
54
+ return "yellow";
55
+ }
56
+
57
+ function formatElapsed(ms: number): string {
58
+ const secs = Math.max(0, Math.round(ms / 1000));
59
+ if (secs < 60) return `${secs}s`.padStart(4);
60
+ const mins = Math.floor(secs / 60);
61
+ const rem = secs % 60;
62
+ return `${mins}m${rem.toString().padStart(2, "0")}`.padStart(4);
63
+ }
64
+
65
+ function headerLine(b: AgentBlockData, now: number, width: number): string {
66
+ const elapsed = formatElapsed(now - b.since);
67
+ const stateLabel = b.variant === "done" ? "done" : b.variant === "failed" ? "failed" : b.state;
68
+ const artifact = b.artifact ? ` · ${b.artifact}` : "";
69
+ const raw = `${GLYPH[b.state]} ${b.agent} · ${stateLabel} · ${elapsed}${artifact}`;
70
+ return raw.length > width ? raw.slice(0, width - 1) + "…" : raw;
71
+ }
72
+
73
+ function bodyLines(b: AgentBlockData): string[] {
74
+ if (b.variant === "done" && b.summary) {
75
+ const lines = b.summary.split("\n").filter((l) => l.trim());
76
+ return lines.slice(0, BLOCK_BODY_MAX);
77
+ }
78
+ if (b.variant === "failed" && b.error) {
79
+ const lines = b.error.split("\n").filter((l) => l.trim());
80
+ return lines.slice(0, BLOCK_BODY_MAX);
81
+ }
82
+ const tail = b.lines.slice(-BLOCK_BODY_MAX);
83
+ while (tail.length < BLOCK_BODY_LINES) tail.unshift("");
84
+ return tail;
85
+ }
86
+
87
+ function truncateLine(s: string, cap: number): string {
88
+ const flat = s.replace(/[`*#]/g, "").replace(/\s+/g, " ").trim();
89
+ return flat.length > cap ? flat.slice(0, cap - 1) + "…" : flat;
90
+ }
91
+
92
+ export function AgentBlock({
93
+ block,
94
+ focused,
95
+ width,
96
+ now,
97
+ }: {
98
+ block: AgentBlockData;
99
+ focused: boolean;
100
+ width: number;
101
+ now: number;
102
+ }) {
103
+ const color = railColor(block.variant, block.state, block.waiting);
104
+ const header = headerLine(block, now, width - 4);
105
+ const body = bodyLines(block);
106
+ const indent = " ".repeat(block.depth);
107
+ const marker = focused ? FOCUS_MARKER + " " : " ";
108
+
109
+ const rows: ReactNode[] = [
110
+ <Text key="h" color={color} bold={block.waiting || focused}>
111
+ {indent}{marker}{RULE} {header}
112
+ </Text>,
113
+ ...body.map((l, i) => (
114
+ <Text key={`b${i}`} color={color} dimColor>
115
+ {indent} {RULE} {truncateLine(l, width - 6 - block.depth * 2)}
116
+ </Text>
117
+ )),
118
+ ];
119
+
120
+ return <Box flexDirection="column">{rows}</Box>;
121
+ }
122
+
123
+ /** The last `n` lines of text, bounded to [1, BLOCK_BODY_MAX]. A single trailing-newline empty segment
124
+ * is dropped so a just-closed line never renders as a blank row. */
125
+ export function tailLines(text: string, n: number): string[] {
126
+ if (!text) return [];
127
+ const keep = Math.min(BLOCK_BODY_MAX, Math.max(1, n));
128
+ const lines = text.split("\n");
129
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
130
+ return lines.slice(-keep);
131
+ }
132
+
133
+ /** Settle state: a block that just completed lingers briefly in `done` before being committed to
134
+ * scrollback. Same pattern as SquadPanes/RollingStream. */
135
+ const SETTLE_MS = 800;
136
+
137
+ interface SettleEntry {
138
+ block: AgentBlockData;
139
+ at: number;
140
+ }
141
+
142
+ export function useBlockSettle(liveBlocks: AgentBlockData[]): { allBlocks: AgentBlockData[]; settled: AgentBlockData[] } {
143
+ const settleRef = useRef<Map<string, SettleEntry>>(new Map());
144
+ const prevRef = useRef<Map<string, AgentBlockData>>(new Map());
145
+
146
+ const liveIds = new Set(liveBlocks.map((b) => b.runId));
147
+ const liveById = new Map(liveBlocks.map((b) => [b.runId, b]));
148
+ const now = Date.now();
149
+ const settle = settleRef.current;
150
+
151
+ for (const id of liveIds) settle.delete(id);
152
+ for (const [id, block] of prevRef.current) {
153
+ if (!liveIds.has(id) && !settle.has(id)) {
154
+ const doneBlock: AgentBlockData = { ...block, variant: "done", lines: block.lines };
155
+ settle.set(id, { block: doneBlock, at: now });
156
+ }
157
+ }
158
+ for (const [id, ent] of settle) {
159
+ if (now - ent.at > SETTLE_MS) settle.delete(id);
160
+ }
161
+ prevRef.current = liveById;
162
+
163
+ const settling = [...settle.values()].map((e) => e.block);
164
+ const settled = settling.filter((b) => now - (settle.get(b.runId)?.at ?? 0) > SETTLE_MS / 2);
165
+
166
+ // Merge live and settling blocks, deduplicating by runId (live takes precedence)
167
+ const allById = new Map<string, AgentBlockData>();
168
+ for (const b of settling) allById.set(b.runId, b);
169
+ for (const b of liveBlocks) allById.set(b.runId, b); // live overrides settling
170
+
171
+ return { allBlocks: [...allById.values()], settled };
172
+ }
173
+
174
+ /** Ticker hook: re-renders at 500ms intervals while blocks are live (for elapsed time). */
175
+ export function useBlockTicker(hasBlocks: boolean): number {
176
+ const [tick, setTick] = useState(0);
177
+ useEffect(() => {
178
+ if (!hasBlocks) return;
179
+ const t = setInterval(() => setTick((n) => n + 1), 500);
180
+ return () => clearInterval(t);
181
+ }, [hasBlocks]);
182
+ return tick;
183
+ }