@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 +4 -0
- package/package.json +46 -0
- package/src/index.tsx +334 -0
- package/src/ui/AgentBlock.tsx +183 -0
- package/src/ui/App.tsx +1404 -0
- package/src/ui/ArtifactBrowser.tsx +451 -0
- package/src/ui/ChatInput.tsx +146 -0
- package/src/ui/OperationView.tsx +185 -0
- package/src/ui/OrgBrowser.tsx +489 -0
- package/src/ui/PlanPanel.tsx +83 -0
- package/src/ui/ProposalCard.tsx +96 -0
- package/src/ui/QuestionCard.tsx +61 -0
- package/src/ui/SquadPanes.tsx +155 -0
- package/src/ui/StatusBar.tsx +72 -0
- package/src/ui/WorkflowBrowser.tsx +141 -0
- package/src/ui/banner.ts +10 -0
- package/src/ui/browser-model.ts +170 -0
- package/src/ui/editor-handoff.ts +51 -0
- package/src/ui/input-history.ts +55 -0
- package/src/ui/input-keys.ts +51 -0
- package/src/ui/input.ts +18 -0
- package/src/ui/markdown-stream.ts +31 -0
- package/src/ui/markdown.ts +45 -0
- package/src/ui/org-browser-model.ts +43 -0
- package/src/ui/slash.ts +270 -0
- package/src/ui/text-buffer.ts +92 -0
- package/src/ui/transcript-hydrate.ts +51 -0
- package/src/ui/transcript-style.ts +124 -0
- package/src/ui/workflow-browser-model.ts +65 -0
package/README.md
ADDED
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
|
+
}
|