@staix/agent-hub 0.12.21 → 0.12.23

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
@@ -0,0 +1,363 @@
1
+ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
2
+ import { taskProgress } from "../ui/task-progress.ts";
3
+ import { backendLabel } from "./status-lines.ts";
4
+ import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
5
+
6
+ export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
7
+ export interface StatusOutput {
8
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; budget?: Record<string, any>;
9
+ tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
10
+ }
11
+ export interface BudgetOutput { budget: Record<string, any>; gate?: number }
12
+ export function outputWidth(env: { isTTY: boolean; columns?: number; COLUMNS?: string }): number | undefined {
13
+ if (env.isTTY) return Math.max(80, Number.isFinite(env.columns) ? env.columns! : 80);
14
+ return env.COLUMNS && /^[1-9][0-9]*$/.test(env.COLUMNS) && Number.isSafeInteger(Number(env.COLUMNS)) ? Number(env.COLUMNS) : undefined;
15
+ }
16
+ interface OutputCell extends Span { atomic?: boolean; items?: string[]; pathItems?: boolean; prefix?: string }
17
+ const span = (text: unknown, tone?: Tone, atomic = false): OutputCell => ({ text: String(text ?? ""), ...(tone ? { tone } : {}), ...(atomic ? { atomic: true } : {}) });
18
+ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
19
+ const width = (value: string) => Bun.stringWidth(value);
20
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
21
+ const characters = (value: string) => [...graphemes.segment(value)].map(unit => unit.segment);
22
+ const peerTone = (id: string): Tone | undefined => id === "claude" ? "peerClaude" : id === "codex" ? "peerCodex" : undefined;
23
+ const shortId = (id: unknown, columns?: number, full = false) => full || columns === undefined ? String(id) : String(id).slice(0, 8);
24
+ function lines(text: string, columns?: number, tone?: Tone, indent = ""): Span[][] {
25
+ return (columns === undefined ? [flat(indent + text)] : wrap(flat(indent + text), columns)).map(text => [span(text, tone)]);
26
+ }
27
+ /** Prefer path separators while retaining every character; only oversized individual segments use ordinary wrapping, with a cell reserved so the segment's trailing slash rides its last piece. */
28
+ function pathChunks(text: string, columns: number): string[] {
29
+ const chunks: string[] = []; let line = "";
30
+ for (const part of text.match(/[^/]+\/?|\//g) ?? []) {
31
+ if (line && width(line + part) > columns) { chunks.push(line); line = ""; }
32
+ if (width(part) > columns) {
33
+ const slash = part.endsWith("/");
34
+ const split = wrap(slash ? part.slice(0, -1) : part, slash ? columns - 1 : columns, 0);
35
+ chunks.push(...split.slice(0, -1)); line = split.at(-1)! + (slash ? "/" : "");
36
+ } else line += part;
37
+ }
38
+ if (line || !chunks.length) chunks.push(line);
39
+ return chunks.flatMap(chunk => wrap(chunk, columns, 0));
40
+ }
41
+ /** Keep logical list items together before breaking oversized paths or words. A separator trails the item it follows: when an item and its separator do not fit, the item moves to the next line whole (or breaks after a slash, reserving a cell for the separator); the separator never gives, never leads a line, never holds one alone. */
42
+ function itemChunks(cell: OutputCell, columns: number): string[] {
43
+ if (!cell.items?.length) return wrap(cell.text, columns, 0);
44
+ const chunks: string[] = []; let line = cell.prefix ?? "";
45
+ for (const [index, item] of cell.items!.entries()) {
46
+ const last = index === cell.items!.length - 1;
47
+ const unit = item + (last ? "" : ",");
48
+ const glue = line && (index || cell.prefix) ? " " : "";
49
+ if (width(line + glue + unit) <= columns) { line += glue + unit; continue; }
50
+ if (line) chunks.push(line);
51
+ const room = last ? columns : columns - 1;
52
+ const split = width(item) <= room ? [item] : cell.pathItems ? pathChunks(item, room) : wrap(item, room, 0);
53
+ // A slashless split keeps three visible graphemes beside its comma when they fit.
54
+ // Rebalance complete units, including combining marks/ZWJ sequences, never UTF-16 halves.
55
+ if (!last && split.length > 1 && !item.includes("/") && characters(split.at(-1)!).filter(unit => width(unit) > 0).length < 3 && split.join("") === item) {
56
+ const units = characters(split.slice(-2).join(""));
57
+ let cut = units.length, visible = 0;
58
+ while (cut > 0 && visible < 3) if (width(units[--cut]!) > 0) visible++;
59
+ const prefix = units.slice(0, cut).join(""), tail = units.slice(cut).join("");
60
+ if (visible === 3 && prefix && width(prefix) <= room && width(tail) <= room) split.splice(-2, 2, prefix, tail);
61
+ }
62
+ if (!last) split[split.length - 1] += ",";
63
+ chunks.push(...split.slice(0, -1)); line = split.at(-1)!;
64
+ }
65
+ if (line || !chunks.length) chunks.push(line);
66
+ return chunks.flatMap(chunk => wrap(chunk, columns, 0));
67
+ }
68
+ /** Wrap complete cells before handing canonical headers and explicit widths to the shared console table. A column that is "-" on every row is dropped at the top level; a band keeps it, so a narrow render carries the same cells as the unbounded one (#284 round 4). */
69
+ function rows(head: string[], data: OutputCell[][], columns?: number, details?: Span[][][], keepDash = false): Span[][] {
70
+ if (!data.length) return [];
71
+ const keep = head.map((_, i) => i).filter(i => keepDash || !data.every(row => flat(row[i]?.text) === "-"));
72
+ head = keep.map(i => head[i]!); data = data.map(row => fitLine(keep.map(i => ({ ...row[i]!, text: flat(row[i]?.text), ...(row[i]?.items ? { items: row[i]!.items!.map(flat), prefix: row[i]!.prefix === undefined ? undefined : flat(row[i]!.prefix) } : {}) })), Infinity));
73
+ const natural = head.map((h, i) => Math.max(width(h), ...data.map(row => width(row[i]!.text))));
74
+ const interleave = (rendered: Span[][], lengths: number[], headerHeight = 1) => {
75
+ if (!details) return rendered;
76
+ let at = headerHeight;
77
+ return [...rendered.slice(0, headerHeight), ...lengths.flatMap((length, i) => { const group = rendered.slice(at, at + length); at += length; return [...group, ...(details[i] ?? [])]; })];
78
+ };
79
+ const renderAtWidths = (widths: number[]) => {
80
+ const lengths: number[] = [];
81
+ const physical = data.flatMap(row => {
82
+ const chunks = row.map((cell, i) => cell.items ? itemChunks(cell, widths[i]!) : head[i] === "ROOT" ? pathChunks(cell.text, widths[i]!) : wrap(cell.text, widths[i]!, 0));
83
+ const length = Math.max(...chunks.map(c => c.length)); lengths.push(length);
84
+ return Array.from({ length }, (_, n) => row.map((cell, i) => ({ ...cell, text: chunks[i]![n] ?? "" })));
85
+ });
86
+ return interleave(table(head, physical, columns ?? Infinity, widths), lengths);
87
+ };
88
+ if (columns === undefined) return interleave(table(head, data, Infinity, natural), data.map(() => 1));
89
+ if (head.length === 1) {
90
+ const size = Math.min(columns, natural[0]!);
91
+ const chunk = (cell: OutputCell) => cell.items ? itemChunks(cell, size) : head[0] === "ROOT" ? pathChunks(cell.text, size) : wrap(cell.text, size, 0);
92
+ const lengths = data.map(row => chunk(row[0]!).length);
93
+ const physical = data.flatMap(row => chunk(row[0]!).map(text => [{ ...row[0]!, text }]));
94
+ return interleave(table(head, physical, columns, [size]), lengths, wrap(head[0]!, size, 0).length);
95
+ }
96
+ const flexible = head.includes("TITLE") ? head.indexOf("TITLE") : head.length - 1;
97
+ // TASKS is not protected: its items stay whole at any width (itemChunks breaks between them), so it wraps
98
+ // before ROOT floors (#284 round 3).
99
+ const protectedColumns = new Set(head.map((h, i) => ["ID", "PROJECT", "TURN", "TASK", "OWNER", "REVIEWER", "AGE", "STAGE", "Q", "!", "REVIEW", "REV", "PEER", "PEERS", "PIDS", "TURNS", "BUSY", "TOKENS", "COUNT", "FIELD", "STATE", "MODE", "LEVEL", "LINK", "CLASS"].includes(h) ? i : -1));
100
+ const words = head.map((_, i) => Math.max(...data.flatMap(row => row[i]!.text.split(/\s+/).map(width))));
101
+ const minimum = head.map((h, i) => Math.max(width(h), protectedColumns.has(i) ? words[i]! : 0,
102
+ Math.max(0, ...data.filter(row => row[i]!.atomic).map(row => width(row[i]!.text))),
103
+ h === "TASKS" ? Math.max(0, ...data.flatMap(row => row[i]!.items?.map((item, j, all) => width(item) + (j < all.length - 1 ? 1 : 0)) ?? [])) : 0));
104
+ // Reserve readable flexible text before a multi-word state consumes the space; atomic words still win.
105
+ // A ROOT column keeps a quarter of a wide table: an item list (TASKS) wraps between its items instead (#284 round 3).
106
+ const pathFloor = head[flexible] === "ROOT" && columns !== undefined && columns > 80 ? Math.floor(columns / 4) : 12;
107
+ if (["TITLE", "CONTEXT", "ROOT", "FILES"].includes(head[flexible]!)) minimum[flexible] = Math.max(minimum[flexible]!, Math.min(natural[flexible]!, pathFloor));
108
+ // A FILES column has no floor of its own: a floor is paid by the time and state cells before the shorter-only
109
+ // comparison below looks at anything, so it would wrap them without the listing getting shorter (#284 round 5).
110
+ // Label/value pairs never become unrelated lists. Narrow values wrap beside their own label.
111
+ if (head[0] === "FIELD" && head[1] === "VALUE" && natural[0]! + minimum[1]! + 2 > columns) {
112
+ if (columns < 12) return [...lines("FIELD VALUE", columns), ...data.flatMap(row => lines(`${row[0]!.text}: ${row[1]!.text}`, columns, row[1]!.tone))];
113
+ const label = Math.min(natural[0]!, columns - 2 - width(head[1]!));
114
+ return renderAtWidths([label, columns - 2 - label]);
115
+ }
116
+ // Narrow tables become linked bands before an id, peer, age, counter or state word would split.
117
+ if (minimum.reduce((sum, n) => sum + n, 0) + 2 * (head.length - 1) > columns) {
118
+ const bands: number[][] = []; let band = [0];
119
+ for (let i = 1; i < head.length; i++) {
120
+ // Keep a board title with its stage meter: if both cannot fit here, give the title the next band's room.
121
+ const stage = head.indexOf("STAGE");
122
+ const trial = [...band, i, ...(i === flexible && stage > i ? [stage] : [])];
123
+ if (trial.reduce((sum, index) => sum + minimum[index]!, 0) + 2 * (trial.length - 1) > columns) {
124
+ bands.push(band);
125
+ band = minimum[0]! + minimum[i]! + 2 <= columns ? [0] : [];
126
+ }
127
+ band.push(i);
128
+ }
129
+ bands.push(band);
130
+ return bands.flatMap((indices, i) => {
131
+ const part = indices.includes(0)
132
+ ? rows(indices.map(index => head[index]!), data.map(row => indices.map(index => row[index]!)), columns, i === bands.length - 1 ? details : undefined, true)
133
+ : data.flatMap((row, n) => [...rows([head[0]!], [[row[0]!]], columns, undefined, true),
134
+ ...rows(indices.map(index => head[index]!), [indices.map(index => row[index]!)], columns, i === bands.length - 1 && details ? [details[n]!] : undefined, true)]);
135
+ return [...(i ? [[]] : []), ...part];
136
+ });
137
+ }
138
+ const available = columns - 2 * (head.length - 1);
139
+ const widths = natural.map((n, i) => i === flexible ? Math.max(minimum[i]!, Math.min(n, 12)) : Math.max(minimum[i]!, Math.min(n, Math.max(24, Math.floor(columns / 3)))));
140
+ // Whole state phrases have priority too: they shrink to word boundaries only if the other columns need the room.
141
+ for (const i of protectedColumns) if (i >= 0) widths[i] = natural[i]!;
142
+ while (widths.reduce((sum, n) => sum + n, 0) > available) {
143
+ const choices = widths.map((n, i) => ({ i, excess: n - minimum[i]! })).filter(item => item.excess > 0);
144
+ if (!choices.length) break;
145
+ choices.sort((a, b) => Number(b.i === flexible) - Number(a.i === flexible) ||
146
+ Number(protectedColumns.has(a.i)) - Number(protectedColumns.has(b.i)) || b.excess - a.excess);
147
+ widths[choices[0]!.i]!--;
148
+ }
149
+ widths[flexible] = Math.max(minimum[flexible]!, Math.min(natural[flexible]!, available - widths.reduce((sum, n, i) => sum + (i === flexible ? 0 : n), 0)));
150
+ // A flexible FILES column grows toward natural while protected columns give down to their longest word, but only
151
+ // when that makes the listing shorter: both layouts are rendered and the taller one is dropped (#284 round 4).
152
+ if (head[flexible] === "FILES") {
153
+ const grown = [...widths];
154
+ while (grown[flexible]! < natural[flexible]!) {
155
+ const donors = grown.map((n, i) => ({ i, room: i === flexible ? 0 : n - Math.max(minimum[i]!, protectedColumns.has(i) ? words[i]! : 0) })).filter(d => d.room > 0);
156
+ if (!donors.length) break;
157
+ donors.sort((a, b) => b.room - a.room);
158
+ grown[donors[0]!.i]!--; grown[flexible]!++;
159
+ }
160
+ if (grown[flexible]! > widths[flexible]!) {
161
+ const grownRender = renderAtWidths(grown);
162
+ const plainRender = renderAtWidths(widths);
163
+ return grownRender.length < plainRender.length ? grownRender : plainRender;
164
+ }
165
+ }
166
+ return renderAtWidths(widths);
167
+ }
168
+ function detail(label: string, text: unknown, columns?: number, tone?: Tone): Span[][] {
169
+ return lines(`${label} ${flat(text)}`, columns, tone, " ");
170
+ }
171
+ function contextText(p: any, now: number): string {
172
+ const c = p.context;
173
+ if (!c) return "unknown";
174
+ return `${c.used === null || c.used === undefined ? "unknown" : `${Math.round(c.used * 100)}%`}${c.freshness === "stale" ? " stale" : ""}${typeof c.measuredAt === "number" ? ` ${relative(c.measuredAt, now)}` : ""}${c.source ? ` ${c.source}` : ""}`;
175
+ }
176
+ export function renderStatus(data: StatusOutput, columns?: number, now = Date.now(), full = false): Span[][] {
177
+ const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
178
+ if (data.cwd) out.push(...lines(data.cwd, columns));
179
+ if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
180
+ for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
181
+ for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
182
+ const s = initialConsoleState(); s.panel = 1; s.peers = data.peers; s.budget = data.budget ?? {};
183
+ const head = Object.values(data.peers).some(p => p.permissionMode && p.permissionMode !== "ask") ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : [...TABLES[1]!];
184
+ head.push("CONTEXT");
185
+ const peerRows = Object.entries(data.peers).map(([id, p]) => {
186
+ const result = cells(s, { id, ...p }, false, now); result[0] = span(id, peerTone(id));
187
+ result.push(span(contextText(p, now))); return result;
188
+ });
189
+ let settling = false;
190
+ const peerDetails = Object.entries(data.peers).map(([id, p]) => {
191
+ const out: Span[][] = [];
192
+ if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
193
+ if (p.heldBy) {
194
+ const note = flat(p.holdNote ?? "");
195
+ const command = `ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`;
196
+ const normalized = note.startsWith(`held by needs_review ${p.heldBy}`)
197
+ ? note.replace(`held by needs_review ${p.heldBy}`, `by needs_review ${shortId(p.heldBy, columns, full)}`)
198
+ : `${shortId(p.heldBy, columns, full)}${note ? `: ${note}` : ""}`;
199
+ out.push(...detail("held", `${normalized}${note.includes(`ahub queue resolve ${p.heldBy}`) ? "" : `; ${command}`}`, columns, "failure"));
200
+ }
201
+ if (p.paused) {
202
+ const budgetPause = s.budget[id]?.paused;
203
+ const pauseText = budgetPause ? `budget: ${budgetPause.reason ?? "threshold"}${typeof budgetPause.resetsAt === "number" ? `; resets ${relative(budgetPause.resetsAt, now)}` : "; reset unknown"}`
204
+ : typeof p.paused === "object" ? `${p.paused.by ?? "paused"}${p.paused.reason ? `: ${p.paused.reason}` : ""}${typeof p.paused.at === "number" ? ` ${relative(p.paused.at, now)}` : ""}`
205
+ : flat(p.paused).replace(/^(budget: .*), resets .+$/, "$1; reset unknown");
206
+ out.push(...detail("paused", pauseText, columns, "attention"));
207
+ }
208
+ if (p.toolsOnly) out.push(...detail("tools-only", flat(p.toolsOnly).replace(/^tools-only:\s*/, ""), columns, "attention"));
209
+ if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
210
+ if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
211
+ return out;
212
+ });
213
+ if (peerRows.length) out.push([], ...rows(head, peerRows, columns, peerDetails));
214
+ else out.push(...lines("no peers attached yet (ahub claude | ahub codex | ahub kimi)", columns));
215
+ if (settling) out.push([], ...lines("settling: the adapter has not confirmed these deliveries yet (Claude: reply or hub_delivery_done). Task state is independent.", columns, "muted"));
216
+ if (data.models?.backends?.length) {
217
+ out.push([], ...rows(["MODEL", "STATE", "ACTIVE", "REQUESTED", "ACTUAL", "PROVIDER"], data.models.backends.map(b => [span(backendLabel(b)), span(b.state ?? "unknown", stateTone(b.state ?? "")), span(b.active || "-"), span(b.requestedModel ?? "-"), span(b.actualModel ?? "-"), span(b.provider ?? "-")]), columns));
218
+ for (const b of data.models.backends) for (const [key, label] of [["coolingUntil", "cooling"], ["failingUntil", "dispatch held"]]) if (b[key!]) { const at = Date.parse(b[key!]); out.push(...detail(`${backendLabel(b)} ${label}`, `${Number.isFinite(at) ? relative(at, now) : "unknown"}${key === "coolingUntil" && typeof b.failures === "number" ? ` after ${b.failures} failures` : key === "failingUntil" ? "; no load moves until then" : ""}`, columns, "attention")); }
219
+ }
220
+ if (data.switchyard) out.push(...detail("switchyard", data.switchyard, columns));
221
+ const counts = Object.entries(data.tasks ?? {}).map(([state, count]) => `${count} ${state}`).join(", ");
222
+ if (counts) out.push([], ...lines(`TASKS ${counts} (ahub board)`, columns));
223
+ return out;
224
+ }
225
+ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _full = false, allTasks: any[] | null = tasks): Span[][] {
226
+ if (!tasks.length) return [[span("no tasks")]];
227
+ const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
228
+ const stages = allTasks === null ? undefined : new Map(taskProgress(allTasks).stages.map(stage => [stage.id, stage]));
229
+ const data = tasks.map(task => {
230
+ const row = cells(s, task, false, now, stages);
231
+ row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
232
+ row[3] = span(row[3]!.text, peerTone(task.reviewer ?? ""));
233
+ row[6] = span(`${task.title}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
234
+ if (allTasks === null) row.pop();
235
+ return row;
236
+ });
237
+ const counts = [...new Set(tasks.map(task => task.state))].map(state => `${tasks.filter(task => task.state === state).length} ${state}`).join(", ");
238
+ return [...rows(allTasks === null ? TABLES[3]!.slice(0, -1) : [...TABLES[3]!], data, columns), [], ...lines(`${plural(tasks.length, "task")}: ${counts}`, columns)];
239
+ }
240
+ export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
241
+ const out: Span[][] = [];
242
+ const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : "fresh", w.stale ? "attention" : undefined)]));
243
+ if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "SOURCE", "MEASURED", "STATE"], windows, columns));
244
+ else out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
245
+ for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
246
+ return out;
247
+ }
248
+ export function renderDoctor(checks: DoctorCheck[], columns?: number, _now = Date.now(), _full = false): Span[][] {
249
+ const out: Span[][] = [];
250
+ const tone = { ok: "success", warn: "attention", fail: "failure", unknown: "muted" } as const;
251
+ for (const section of ["Tools", "Hub", "Config", "Models", "Memory"] as const) {
252
+ const group = checks.filter(check => check.section === section); if (!group.length) continue;
253
+ if (out.length) out.push([]); out.push([span(section, "strong")]);
254
+ out.push(...rows(["LEVEL", "CHECK", "FINDING"], group.map(check => [span(check.level, tone[check.level]), span(check.name), span(check.detail || "finding unknown")]), columns));
255
+ }
256
+ const count = (level: DoctorCheck["level"]) => checks.filter(c => c.level === level).length;
257
+ out.push([], ...lines(`${count("fail")} failure${count("fail") === 1 ? "" : "s"}, ${count("warn")} warning${count("warn") === 1 ? "" : "s"}, ${count("ok")} ok, ${count("unknown")} unknown`, columns));
258
+ return out;
259
+ }
260
+
261
+ export function renderProjects(projects: any[], columns?: number, _now = Date.now()): Span[][] {
262
+ if (!projects.length) return lines("No registered projects. Run ahub init in a project directory.", columns);
263
+ return rows(["PROJECT", "STATE", "PEERS", "TASKS", "ROOT"], projects.map(project => {
264
+ const tasks = Object.entries(project.status?.tasks ?? {}).map(([state, n]) => `${n} ${state}`).join(", ");
265
+ return [span(project.id, "info"), span(project.state ?? "unknown", stateTone(project.state ?? "")),
266
+ span(project.status ? Object.keys(project.status.peers ?? {}).length : "unknown", "number"), { ...span(tasks || "-"), items: Object.entries(project.status?.tasks ?? {}).map(([state, n]) => `${n} ${state}`) }, span(project.root)];
267
+ }), columns, projects.map(project => project.error ? detail("error", project.error, columns, "failure") : []));
268
+ }
269
+ export function renderQueue(deliveries: any[], columns?: number, now = Date.now()): Span[][] {
270
+ if (!deliveries.length) return lines("no retained deliveries", columns);
271
+ const s = initialConsoleState(); s.panel = 4;
272
+ const data = deliveries.map(delivery => {
273
+ const row = cells(s, { ...delivery, id: String(delivery.id) }, false, now);
274
+ row[1] = span(row[1]!.text, peerTone(delivery.peer)); return row;
275
+ });
276
+ return rows([...TABLES[4]!], data, columns, deliveries.map(delivery => delivery.important ? detail("priority", "important", columns, "attention") : []));
277
+ }
278
+ const moment = (value: unknown, now: number) => {
279
+ const at = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
280
+ return Number.isFinite(at) ? relative(at, now) : "unknown";
281
+ };
282
+ export function renderTurns(turns: any[], columns?: number, now = Date.now()): Span[][] {
283
+ if (!turns.length) return lines("no turns recorded (they need a git work tree and snapshots.enabled)", columns);
284
+ return rows(["TURN", "PEER", "STARTED", "STATE", "FILES"], turns.map(turn => {
285
+ const state = !turn.ended ? "running" : !turn.end_tree ? "no end snapshot" : "completed";
286
+ const files = turn.changed ?? [];
287
+ return [span(turn.id, "info"), span(turn.peer ?? "unknown", peerTone(turn.peer)), span(moment(turn.started, now), "muted"),
288
+ span(state, state === "running" ? "attention" : state === "no end snapshot" ? "failure" : "success"), { ...span(`${plural(files.length, "file")}${files.length ? `: ${files.join(", ")}` : ""}`), ...(files.length ? { items: files, pathItems: true, prefix: `${plural(files.length, "file")}:` } : {}) }];
289
+ }), columns);
290
+ }
291
+ export function renderOrphans(orphans: { project: any; pids: number[] }[], columns?: number, _now = Date.now(), killing = false): Span[][] {
292
+ if (!orphans.length) return lines("no orphaned hub registrations", columns);
293
+ return [...lines("orphaned hub registrations (the project root is gone):", columns, "strong"),
294
+ ...rows(["PROJECT", "STATE", "PIDS", "ROOT"], orphans.map(({ project, pids }) => [span(project.id, "info"), span(pids.length ? "live" : "no live process", pids.length ? "attention" : "muted"), span(pids.length ? pids.join(", ") : "-", "number"), span(project.root)]), columns),
295
+ ...(killing ? [] : [[], ...lines("kill live orphans with ahub doctor --orphans --kill", columns)]),
296
+ ...orphans.flatMap(({ project }) => detail("forget", `ahub projects remove ${project.id}`, columns))];
297
+ }
298
+ const TIME_FIELD = /^(?:at|ts|started|ended|created|updated|expires)$|(?:At|Until)$/;
299
+ const ID_FIELD = /^(?:id|trace|previousId|sessionId|threadId|operationId|turnId|envelopeIds)$/;
300
+ /** Labelled scalar leaves preserve the daemon's public view without a raw document or inferred privacy changes. */
301
+ function fields(value: unknown, columns: number | undefined, now: number, prefix = ""): Span[][] {
302
+ const leaves: OutputCell[][] = [];
303
+ const visit = (current: unknown, label: string, key: string) => {
304
+ if (Array.isArray(current)) {
305
+ if (!current.length) leaves.push([span(label), span("none", "muted")]);
306
+ else current.forEach((item, index) => visit(item, `${label}${label ? " " : ""}${index + 1}`, key));
307
+ return;
308
+ }
309
+ if (current && typeof current === "object") {
310
+ const entries = Object.entries(current);
311
+ if (!entries.length) leaves.push([span(label), span("none", "muted")]);
312
+ else for (const [child, item] of entries) visit(item, `${label}${label ? " " : ""}${child}`, child);
313
+ return;
314
+ }
315
+ let text: string;
316
+ if (current === null || current === undefined) text = "unknown";
317
+ else if (TIME_FIELD.test(key)) text = moment(current, now);
318
+ else if (/(?:^| )elapsed_ms(?: |$)/.test(label) && typeof current === "number") text = duration(current);
319
+ else text = flat(current);
320
+ leaves.push([span(label || "value"), span(text, ID_FIELD.test(key) ? "info" : key === "state" ? stateTone(text) : typeof current === "number" ? "number" : TIME_FIELD.test(key) ? "muted" : undefined, ID_FIELD.test(key))]);
321
+ };
322
+ visit(value, prefix, prefix);
323
+ return rows(["FIELD", "VALUE"], leaves, columns);
324
+ }
325
+ export function renderQueueShow(delivery: any, columns?: number, now = Date.now()): Span[][] {
326
+ const out = [...lines("Delivery", columns, "strong"), ...fields(delivery, columns, now)];
327
+ if (delivery?.state === "needs_review" && delivery.id) out.push([], ...detail("resolve", `ahub queue resolve ${delivery.id} --action completed|retry|discard --reason <text>`, columns, "failure"));
328
+ return out;
329
+ }
330
+ export function renderModelsStatus(status: unknown, columns?: number, now = Date.now()): Span[][] {
331
+ return [...lines("Models", columns, "strong"), ...fields(status, columns, now)];
332
+ }
333
+ export function renderExecutionBudgetStatus(budgets: unknown, columns?: number, now = Date.now()): Span[][] {
334
+ return [...lines("Execution budget", columns, "strong"), ...(Array.isArray(budgets) && !budgets.length ? lines("No execution budgets configured.", columns) : budgets === undefined ? lines("No matching execution budget.", columns) : fields(budgets, columns, now))];
335
+ }
336
+ /** Retain the report's coverage and attribution sentences; add sections and aligned counters for scanning. */
337
+ export function renderReport(report: Report | TaskReport, columns?: number, now = Date.now(), byTask = false): Span[][] {
338
+ const out = lines(`period: ${report.from ? moment(report.from, now) : "-"} .. ${report.to ? moment(report.to, now) : "-"}`, columns, "muted");
339
+ const formatted = byTask ? formatTaskReport(report as TaskReport) : formatReport(report as Report);
340
+ if (!byTask) {
341
+ const r = report as Report;
342
+ out.push([], ...lines("Counters", columns, "strong"), ...rows(["METRIC", "COUNT"], [
343
+ [span("messages"), span(r.messages.total, "number")], [span("overflow"), span(r.messages.overflow, "number")],
344
+ [span("undeliverable"), span(r.messages.undeliverable, "number")], [span("messages per task"), span(r.messages.perTask, "number")],
345
+ [span("overlap warnings"), span(r.overlaps.warnings, "number")], [span("task pairs"), span(r.overlaps.pairs, "number")],
346
+ [span("edit conflicts"), span(r.conflicts, "number")], [span("quota readings"), span(r.quota.readings, "number")], [span("hard limits"), span(r.quota.hard, "number")],
347
+ ], columns));
348
+ }
349
+ let previous = "";
350
+ const section = (line: string) => byTask
351
+ ? line.startsWith("task #") ? "Tasks" : line.startsWith("class ") ? "Classes" : line.startsWith("unattributed:") ? "Unattributed" : line.startsWith("before attribution:") ? "Before attribution" : previous
352
+ : line.startsWith("peer ") ? "Peers" : line.startsWith("usage ") ? "Usage" : line.startsWith("supervision ") ? "Supervision" : line.startsWith("conductor ") ? "Conductor" : line.startsWith("messages:") ? "Messages" : line.startsWith("overlap warnings:") ? "Coordination" : line.startsWith("task events:") ? "Tasks" : line.startsWith("quota readings:") ? "Quota" : line.startsWith("route ") ? "Routes" : "Report";
353
+ for (const sentence of formatted.slice(1)) {
354
+ const name = section(sentence);
355
+ if (name !== previous) {
356
+ out.push([], ...lines(name, columns, "strong")); previous = name;
357
+ if (name === "Peers" && !byTask) out.push(...rows(["PEER", "TURNS", "BUSY", "TOKENS"], Object.entries((report as Report).peers).map(([peer, p]) => [span(peer, peerTone(peer)), span(p.turns ?? "unknown", "number"), span(`${p.busyMinutes}m`, "muted"), span(p.tokens || "unknown", "number")]), columns));
358
+ if (name === "Tasks" && byTask) out.push(...rows(["TASK", "CLASS", "OUTCOME", "TURNS", "WALL"], Object.entries((report as TaskReport).tasks).map(([id, task]) => [span(`#${id}${task.pii ? " [pii]" : ""}`, "taskRef"), span(task.class ?? "unknown"), span(task.outcome ?? "unknown", stateTone(task.outcome ?? "")), span(task.turns, "number"), span(task.wallMs === null ? "unknown" : duration(task.wallMs), "muted")]), columns));
359
+ }
360
+ out.push(...lines(sentence, columns, undefined, " "));
361
+ }
362
+ return out;
363
+ }
@@ -4,6 +4,7 @@ import { loadConfig } from "../hub/daemon.ts";
4
4
  import { childEnv } from "../hub/child-process.ts";
5
5
  import { relayModelIds } from "../models/relay.ts";
6
6
  import { buildPiLaunch } from "../pi/launch.ts";
7
+ import { startModeOf } from "../hub/start-mode.ts";
7
8
  import { buildLaunch, buildKimiLaunch, claudeObservationHooks, type Launch } from "./launch.ts";
8
9
 
9
10
  const REDACTED = "[redacted]";
@@ -62,7 +63,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
62
63
  launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
63
64
  unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
64
65
  } else {
65
- const mode = value("--mode") ?? "headless";
66
+ const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
66
67
  const backend = value("--backend") ?? config.pi.backend;
67
68
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
68
69
  for (let i = 0; i < args.length; i += 2) if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!)) throw new Error("unknown Pi option");
@@ -67,6 +67,23 @@ export async function registryRelease(version: string, run: RunCommand = runComm
67
67
  return { version, integrity: data["dist.integrity"] };
68
68
  }
69
69
 
70
+ /** #272: the registry's latest release, the target when `--to` is left out. */
71
+ export async function latestRelease(run: RunCommand = runCommand): Promise<string> {
72
+ const result = await run(["npm", "view", "@staix/agent-hub", "version", "--json"], { timeoutMs: 30_000 });
73
+ if (result.code !== 0) throw new Error("registry metadata unavailable for @staix/agent-hub; name the release with --to <version>");
74
+ let version: unknown;
75
+ try { version = JSON.parse(result.stdout); } catch { /* reported below */ }
76
+ if (typeof version !== "string") throw new Error("registry returned an unexpected latest release; name the release with --to <version>");
77
+ return exactVersion(version);
78
+ }
79
+
80
+ /** ponytail: compares the numeric triple only, so a pre-release never counts as newer than its own release; a semver compare if pre-releases are ever published. */
81
+ export function newerVersion(a: string, b: string): boolean {
82
+ const [x, y] = [a, b].map((v) => v.split("-")[0]!.split(".").map(Number));
83
+ for (let i = 0; i < 3; i++) if (x![i] !== y![i]) return (x![i] ?? 0) > (y![i] ?? 0);
84
+ return false;
85
+ }
86
+
70
87
  /** Stage outside the mutable global install; retain successful versions for recovery. */
71
88
  export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
72
89
  exactVersion(version);
package/src/cli/reset.ts CHANGED
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { Bus } from "../hub/bus.ts";
6
6
  import { DeliveryJournal } from "../hub/delivery-journal.ts";
7
7
  import type { Project } from "../hub/registry.ts";
8
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
8
9
 
9
10
  /** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
10
11
  export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
@@ -60,6 +61,7 @@ export function planReset(stateDir: string, projectId: string): ResetPlan {
60
61
  const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
61
62
  const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
62
63
  try {
64
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
63
65
  const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
64
66
  const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
65
67
  plan.budgetPauses = peers("budget_pauses");
@@ -121,6 +123,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
121
123
  } finally { bus.closeJournal(); }
122
124
  const db = new Database(file);
123
125
  try {
126
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
124
127
  db.transaction(() => {
125
128
  for (const table of ["budget_pauses", "conductor_holds"]) {
126
129
  if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
@@ -15,31 +15,3 @@ export function backendLabel(backend: BackendRow): string {
15
15
  export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
16
16
  return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
17
17
  }
18
-
19
- export function peerLine(id: string, p: PeerRow): string {
20
- return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
21
- // Which queued messages the peer will not wait out the batch window for (issue #41).
22
- (p.queuedImportant ? ` (${p.queuedImportant} important)` : "") +
23
- (p.needsReview ? ` needs review ${p.needsReview}` : "") +
24
- (p.liveAccepted?.length ? ` awaiting settlement ${p.liveAccepted.join(", ")} (adapter completion; Claude: reply or hub_delivery_done; task state is independent)` : "") +
25
- (p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
26
- (p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
27
- (p.attached === false ? " disconnected" : "") +
28
- (p.toolsOnly ? ` ${p.toolsOnly}` : "") +
29
- (p.permissionMode && p.permissionMode !== "ask" ? ` permission: ${p.permissionMode}` : "") +
30
- (p.paused ? ` (${p.paused})` : "") +
31
- (p.servedBy ? ` last call: ${p.servedBy}` : "") +
32
- // Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
33
- // meant reading status.json by hand.
34
- (p.requestedModel ? ` model: ${p.requestedModel}` : "") +
35
- (p.context ? ` ${contextLine(p.context)}` : "");
36
- }
37
-
38
- export function backendLine(backend: BackendRow): string {
39
- return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
40
- (backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
41
- (backend.actualModel ? ` actual ${backend.actualModel}` : "") +
42
- (backend.provider ? ` provider ${backend.provider}` : "") +
43
- (backend.coolingUntil ? ` cooling down until ${backend.coolingUntil} after ${backend.failures ?? "?"} failures` : "") +
44
- (backend.failingUntil ? ` last dispatch failed, no load moves until ${backend.failingUntil}` : "");
45
- }
@@ -154,7 +154,7 @@ export interface RecordedTerminalLaunch {
154
154
  }
155
155
 
156
156
  const MAX_WAIT_MS = 10 * 60 * 1000;
157
- const MAX_COMMAND_MS = 30_000;
157
+ export const MAX_COMMAND_MS = 30_000;
158
158
  /** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
159
159
  const EXIT_SLICE_MS = 5_000;
160
160
  const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
@@ -273,6 +273,32 @@ function defaultRunner(executable: string): CommandRunner {
273
273
  };
274
274
  }
275
275
 
276
+ /** The Orca executable a start or a recovery would run (`orca`, or what the environment pins). */
277
+ export const orcaExecutable = (): string => resolveOrcaExecutable();
278
+
279
+ /**
280
+ * #269: the Orca worktree a terminal for this project can be opened in: the one this process runs in, or the one the
281
+ * last recorded `ahub <peer>` launch of this project used. Undefined when there is none or the record does not read.
282
+ * ponytail: the environment's worktree is taken as this project's; a hub started from another project's Orca terminal
283
+ * (`ahub --project`, the dashboard manager's start, an upgrade or restart that brings several projects up from one
284
+ * terminal) opens the terminal there and `ahub <peer>` then fails its root readback. Ask Orca for the worktree's path
285
+ * and compare it if that case shows up.
286
+ */
287
+ export function startTerminalWorktree(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
288
+ if (env.ORCA_WORKTREE_ID) return env.ORCA_WORKTREE_ID;
289
+ const records = launchRecords(stateDir);
290
+ return records === "unreadable" ? undefined : records.rows.filter((row) => row.projectRoot === projectRoot).at(-1)?.worktreeId;
291
+ }
292
+
293
+ /** #269: a new Orca terminal running `command` (a complete, already quoted command line); the terminal's handle. */
294
+ export async function openStartTerminal(worktreeId: string, command: string, title: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<string> {
295
+ const config = normalizeOptions(options);
296
+ const selector = worktreeId.startsWith("id:") ? worktreeId : `id:${worktreeId}`;
297
+ const handle = nestedString(terminalObject(await run(config.runner, ["terminal", "create", "--worktree", selector, "--command", command, "--title", title, "--json"])), ["handle"]);
298
+ if (!handle) throw new Error("Orca create returned no terminal handle");
299
+ return handle;
300
+ }
301
+
276
302
  const RECORD_FILE = "terminal-recovery.json";
277
303
 
278
304
  export function recordPath(stateDir: string): string {