@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/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
|
+
}
|