@staix/agent-hub 0.12.22 → 0.12.24

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 (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
package/src/cli/pet.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
4
+ import { processLiveness } from "../pi/process-signature.ts";
5
+ import { characterAttentionLine } from "./attention.ts";
6
+ import type { CharacterId } from "../pet/frame.ts";
7
+ import type { NeedItem, NeedsSummary } from "../ui/needs.ts";
8
+
9
+ const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
10
+ const count = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
11
+
12
+ /** Parsed status metadata is copied by allowlist; source text and extra fields are never rendered. */
13
+ export function parseNeedsSummary(raw: unknown): NeedsSummary | undefined {
14
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
15
+ const row = raw as Record<string, unknown>;
16
+ if (!count(row.act) || !count(row.watch) || typeof row.state !== "string" || !["waiting", "blocked", "review", "working", "idle", "sleeping"].includes(row.state)) return;
17
+ let first: NeedItem | undefined;
18
+ if (row.first !== undefined) {
19
+ if (!row.first || typeof row.first !== "object" || Array.isArray(row.first)) return;
20
+ const item = row.first as Record<string, unknown>;
21
+ if (typeof item.kind !== "string" || typeof item.level !== "string" || !["approval", "default", "storage", "recovery", "delivery", "review", "budget", "offline"].includes(item.kind) || !["act", "watch"].includes(item.level)) return;
22
+ if (item.peer !== undefined && !id(item.peer)) return;
23
+ if (item.id !== undefined && !id(item.id) && !(count(item.id) && item.id > 0)) return;
24
+ if (item.until !== undefined && !count(item.until) || item.since !== undefined && !count(item.since)) return;
25
+ first = { kind: item.kind as NeedItem["kind"], level: item.level as NeedItem["level"],
26
+ ...(item.peer !== undefined ? { peer: item.peer as string } : {}), ...(item.id !== undefined ? { id: item.id as string | number } : {}),
27
+ ...(item.until !== undefined ? { until: item.until as number } : {}), ...(item.since !== undefined ? { since: item.since as number } : {}) };
28
+ }
29
+ if ((row.act + row.watch > 0) !== !!first) return;
30
+ if (row.act > 0 && first?.level !== "act" || row.act === 0 && first?.level === "act") return;
31
+ if (row.act === 0 && ["waiting", "blocked", "review"].includes(row.state) || row.act > 0 && ["working", "idle", "sleeping"].includes(row.state)) return;
32
+ if (first?.level === "watch" && !["recovery", "budget", "offline"].includes(first.kind) || first?.level === "act" && ["budget", "offline"].includes(first.kind)) return;
33
+ if (row.state === "waiting" && !["approval", "default"].includes(first?.kind ?? "")) return;
34
+ if (row.state === "blocked" && !["storage", "recovery", "delivery"].includes(first?.kind ?? "")) return;
35
+ if (row.state === "review" && first?.kind !== "review") return;
36
+ return { act: row.act, watch: row.watch, state: row.state as NeedsSummary["state"], ...(first ? { first } : {}) };
37
+ }
38
+
39
+ export interface PetView { summary?: NeedsSummary; unavailable: boolean; runnerUnknown?: boolean }
40
+ export interface PetRecoveryReader { lock(): string | undefined; runner(id: string): number | "unknown" | undefined }
41
+ const recoveryReader: PetRecoveryReader = { lock: recoveryLock, runner: recoveryRunner };
42
+
43
+ /** A recovery lock is read-only evidence; this function never resumes or changes an operation. */
44
+ export function petView(status: unknown, reader: PetRecoveryReader = recoveryReader): PetView {
45
+ const row = status && typeof status === "object" && !Array.isArray(status) ? status as Record<string, unknown> : undefined;
46
+ const base = parseNeedsSummary(row?.needs);
47
+ let operation: string | undefined;
48
+ try { operation = reader.lock(); }
49
+ catch { return { summary: base, unavailable: true, runnerUnknown: true }; }
50
+ if (!operation) return { summary: base, unavailable: !base };
51
+ if (!id(operation)) return { summary: base, unavailable: true, runnerUnknown: true };
52
+ let runner: number | "unknown" | undefined;
53
+ try { runner = reader.runner(operation); } catch { runner = "unknown"; }
54
+ const active = runner !== undefined;
55
+ const item: NeedItem = { kind: "recovery", level: active ? "watch" : "act", id: operation };
56
+ const reported = row?.recovery;
57
+ const included = base && reported && typeof reported === "object" && (reported as Record<string, unknown>).operationId === operation && base.watch > 0 ? 1 : 0;
58
+ const act = (base?.act ?? 0) + (active ? 0 : 1), watch = (base?.watch ?? 0) - included + (active ? 1 : 0);
59
+ const first = base?.first;
60
+ const precedes = !active && (!first || first.level === "watch" || !["approval", "default", "storage"].includes(first.kind));
61
+ const selected = precedes || first?.kind === "recovery" && first.id === operation ? item : first ?? item;
62
+ const state = !active && base?.state !== "waiting" ? "blocked" : base?.state ?? "sleeping";
63
+ return { summary: { act, watch, state, first: selected }, unavailable: !base, ...(runner === "unknown" ? { runnerUnknown: true } : {}) };
64
+ }
65
+
66
+ export function readPet(stateDir: string, reader: PetRecoveryReader = recoveryReader, alive = processLiveness): PetView {
67
+ let status: unknown;
68
+ try {
69
+ const row = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
70
+ const summary = parseNeedsSummary(row.needs);
71
+ const expired = summary?.first?.kind === "approval" && summary.first.until !== undefined && summary.first.until <= Date.now();
72
+ if (!expired && Number.isSafeInteger(row.pid) && row.pid > 0 && alive(row.pid, row.pidSignature) === "live") status = row;
73
+ } catch { /* Missing, malformed or unconfirmed owner data is unavailable, never an empty live hub. */ }
74
+ return petView(status, reader);
75
+ }
76
+
77
+ export function renderPet(view: PetView, short = false, character: CharacterId = "stai"): string {
78
+ if (short) return view.summary?.act ? `ahub!${view.summary.act}` : "";
79
+ if (!view.summary) return "attention unavailable";
80
+ const value = view.summary;
81
+ const line = characterAttentionLine({ ...value, items: value.first ? [value.first] : [] }, character, 0, false, false);
82
+ return line + (view.unavailable ? " (status unavailable)" : "") + (view.runnerUnknown ? " (runner unknown)" : "");
83
+ }
@@ -1,10 +1,14 @@
1
+ import { jcodeCommand } from "../adapters/jcode.ts";
2
+ import { qwenSessionId } from "../hub/crash.ts";
1
3
  import { readFileSync } from "node:fs";
2
4
  import { join, resolve } from "node:path";
3
5
  import { loadConfig } from "../hub/daemon.ts";
4
6
  import { childEnv } from "../hub/child-process.ts";
5
7
  import { relayModelIds } from "../models/relay.ts";
6
8
  import { buildPiLaunch } from "../pi/launch.ts";
7
- import { buildLaunch, buildKimiLaunch, claudeObservationHooks, type Launch } from "./launch.ts";
9
+ import { startModeOf } from "../hub/start-mode.ts";
10
+ import { buildLaunch, buildKimiLaunch, buildQwenLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
11
+ import { kimiMcpPlan } from "./kimi-mcp.ts";
8
12
 
9
13
  const REDACTED = "[redacted]";
10
14
  const UNRESOLVED = "[unresolved]";
@@ -18,11 +22,12 @@ function nativeCommand(command: string[], trusted: string[]): string[] {
18
22
  return command.map((value, i) => value === trusted[i] ? value : REDACTED);
19
23
  }
20
24
  /** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
21
- export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
25
+ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
22
26
  const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
23
27
  const config = loadConfig(cwd);
24
28
  const unresolved: { field: string; reason: string }[] = [];
25
29
  let launch: Launch;
30
+ let kimiMetadata: { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } } | undefined;
26
31
  let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
27
32
  const value = (flag: string) => {
28
33
  const at = args.indexOf(flag);
@@ -57,12 +62,41 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
57
62
  const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
58
63
  launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
59
64
  unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
65
+ } else if (tool === "jcode") {
66
+ const mode = value("--mode") ?? "headless", model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
67
+ if (mode !== "headless") throw new Error("Jcode TUI driving is unverified");
68
+ for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
69
+ if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
70
+ const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
71
+ launch = { cmd: cmd[0]!, args: cmd.slice(1) };
72
+ unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
73
+ unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
74
+ } else if (tool === "qwen") {
75
+ const mode = value("--mode") ?? "headless", model = value("--model");
76
+ if (mode !== "headless") throw new Error("Qwen TUI is not offered before verification");
77
+ for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
78
+ const sessionId = value("--session-id");
79
+ if (sessionId !== undefined && !qwenSessionId(sessionId)) throw new Error("invalid Qwen session identity");
80
+ if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
81
+ launch = buildQwenLaunch(nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model ? REDACTED : undefined);
82
+ unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
60
83
  } else if (tool === "kimi") {
61
84
  const model = value("--model");
62
- launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
63
- unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
85
+ const mode = value("--mode") ?? startModeOf(config.peers, "kimi");
86
+ if (mode !== "tui" && mode !== "headless") throw new Error("invalid Kimi mode");
87
+ for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Kimi option");
88
+ if (mode === "headless") {
89
+ launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
90
+ unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
91
+ } else {
92
+ const native = buildKimiTuiLaunch(config.kimi_cmd, model ? REDACTED : undefined);
93
+ launch = { ...native, cmd: native.cmd === "kimi" ? "kimi" : REDACTED, args: native.args.map(arg => arg === "--model" || arg === REDACTED ? arg : REDACTED) };
94
+ envNames = Object.keys(nativeLaunchEnv("kimi", childEnv())).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
95
+ }
96
+ const { file, action, changed, tracked } = kimiMcpPlan(cwd, stateDir, join(import.meta.dir, "../../plugins/agent-hub/server.js"), mode);
97
+ kimiMetadata = { mode, mcp: { file, action, changed, tracked } };
64
98
  } else {
65
- const mode = value("--mode") ?? "headless";
99
+ const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
66
100
  const backend = value("--backend") ?? config.pi.backend;
67
101
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
68
102
  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");
@@ -87,5 +121,5 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
87
121
  unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
88
122
  unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
89
123
  }
90
- return { tool, cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
124
+ return { tool, ...(kimiMetadata ?? {}), cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
91
125
  }
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}`);
@@ -1,7 +1,7 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
2
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
3
 
4
- export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
4
+ export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
5
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
6
6
 
7
7
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -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
- }
@@ -8,6 +8,7 @@ export function renderTailEvent(e: BusEvent): string {
8
8
  }
9
9
  if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
10
10
  if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
11
+ if (e.t === "off_roster") return ` . ${e.env.id} retained off roster for ${e.peers.join(", ")}`;
11
12
  if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
12
13
  const { env } = e;
13
14
  const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
@@ -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 {
@@ -1,3 +1,5 @@
1
+ import { qwenSessionId } from "../hub/crash.ts";
2
+ import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
1
3
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import { basename, join, resolve } from "node:path";
@@ -83,11 +85,17 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
83
85
  ...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
84
86
  }
85
87
 
88
+ const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
89
+
86
90
  async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
87
- const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, 30_000, protocol);
91
+ const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
88
92
  try {
89
- const reply = await client.request(message, 30_000);
90
- if (reply.ok === false) throw new Error(reply.error ?? "recovery control request failed");
93
+ const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
94
+ if (reply.ok === false) {
95
+ // An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
96
+ if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
97
+ throw new Error(reply.error ?? "recovery control request failed");
98
+ }
91
99
  return reply;
92
100
  } finally { client.close(); }
93
101
  }
@@ -148,7 +156,8 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
148
156
  // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
149
157
  // caller reads that as not inspected, never as stopped.
150
158
  const claim = registeredProjects().find((p) => p.id === project.id);
151
- const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
159
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS);
160
+ if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
152
161
  const control = readControl(project.stateDir);
153
162
  const sourceProtocol = control?.protocol;
154
163
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
@@ -161,11 +170,16 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
161
170
  ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
162
171
  };
163
172
  let status = base.status;
164
- if (!status && legacySupported) {
173
+ if (!status && control && sourceProtocol === PROTOCOL) return {
174
+ state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
175
+ ...(control.instanceId ? { instanceId: control.instanceId } : {}), protocol: control.protocol,
176
+ };
177
+ if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
165
178
  try {
166
179
  const readback = await rpc(project, { t: "status" }, sourceProtocol);
167
180
  status = readback.status;
168
- } catch {
181
+ } catch (error) {
182
+ if (isDatabaseBusy(error)) throw error;
169
183
  // server.stop() runs before state-file removal, so a stopping hub briefly refuses
170
184
  // connections with its manifest still on disk. Never infer stopped from a connect
171
185
  // failure: callers poll again and see "stopped" once the manifest is gone.
@@ -176,7 +190,8 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
176
190
  if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
177
191
  let response: any;
178
192
  try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
179
- catch {
193
+ catch (error) {
194
+ if (isDatabaseBusy(error)) throw error;
180
195
  // A stop can complete between authenticated status and the second read. Never infer stopped
181
196
  // from a connection failure: the coordinator will inspect the ownership manifest again.
182
197
  return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
@@ -224,6 +239,10 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
224
239
  const reconnectOnly: string[] = [];
225
240
  for (const peer of source.peers) {
226
241
  if (peer.state === "offline") continue;
242
+ if (peer.id === "kimi" && peer.args?.mode === "tui") {
243
+ blockers.push("kimi: its pull-only terminal has no automatic restore; end it in that terminal, then make a new recovery plan (its session is not resumed by the hub)");
244
+ continue;
245
+ }
227
246
  if (peer.id === "claude") {
228
247
  // #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
229
248
  // that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
@@ -251,14 +270,17 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
251
270
  continue;
252
271
  }
253
272
  }
273
+ if (peer.id === "jcode") { blockers.push("jcode: source-pinned native load cannot prove conversation identity; end the peer and make a new recovery plan before effects"); continue; }
254
274
  if (peer.id === "codex" || peer.id === "claude") {
255
275
  const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
256
276
  if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
257
277
  else sessions[peer.id] = session;
278
+ } else if (peer.id === "qwen" || peer.id === "jcode") {
279
+ if (!qwenSessionId(peer.sessionId)) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
258
280
  } else if (peer.id === "pi" && peer.args?.mode === "tui") {
259
281
  if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required; next action: reconnect the original Pi session and make a new recovery plan");
260
282
  else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
261
- } else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
283
+ } else if (peer.id !== "kimi" && peer.id !== "qwen" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
262
284
  }
263
285
  const terminals = await inspectTerminals(project.root, sessions, {
264
286
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
@@ -435,6 +457,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
435
457
  // A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
436
458
  if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
437
459
  if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
460
+ if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "qwen" && peer.state !== "offline"))) {
461
+ const catalog = join(target.root, "src/hub/peer-catalog.ts");
462
+ const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
463
+ if (!peers || !/["']qwen["']/.test(peers)) throw new FinalRefusal("target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged");
464
+ }
465
+ if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
466
+ const catalog = join(target.root, "src/hub/peer-catalog.ts");
467
+ const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
468
+ if (!peers || !/["']jcode["']/.test(peers)) throw new FinalRefusal("target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged");
469
+ }
438
470
  // Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
439
471
  if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
440
472
  throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
@@ -498,11 +530,25 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
498
530
  const live = await inspectRecovery(planned.project);
499
531
  if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
500
532
  if (group === "native") {
501
- for (const peer of planned.source.peers.filter((p) => ["kimi", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
533
+ for (const peer of planned.source.peers.filter((p) => ["kimi", "qwen", "jcode", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
502
534
  const current = live.peers.find((p) => p.id === peer.id);
503
535
  if (current && current.state !== "offline") continue;
504
536
  const args = [peer.id];
505
- for (const key of ["mode", "backend", "model", "route"]) if (typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
537
+ if (peer.id === "kimi" && peer.args?.mode === "tui") throw new Error("Kimi's pull-only terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan");
538
+ for (const key of ["mode", "backend", "model", "route"]) if (!(peer.id === "kimi" && key === "mode") && typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
539
+ if (peer.id === "kimi") {
540
+ // ponytail: a staged target's fixed catalog is the capability signal; replace this source check with
541
+ // an explicit recovery capability once targets publish one. Older targets have only headless Kimi.
542
+ const modeFile = join(op.targetRoot!, "src/hub/start-mode.ts");
543
+ const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
544
+ if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
545
+ }
546
+ // #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
547
+ // one is filtered out above), so a snapshot that recorded no mode still restores it headless.
548
+ if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
549
+ if (peer.id === "qwen" && peer.sessionId) args.push("--session-id", peer.sessionId);
550
+ if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
551
+ if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
506
552
  if (peer.id === "pi") {
507
553
  const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
508
554
  if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
@@ -1,8 +1,9 @@
1
+ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
5
- import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
6
+ import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
6
7
  import { hubHome } from "../hub/project.ts";
7
8
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
8
9
 
@@ -82,6 +83,7 @@ export interface RecoveryOperation {
82
83
  pluginInstalled?: boolean;
83
84
  globalInstalled?: boolean;
84
85
  error?: string;
86
+ failure?: { code: "database_busy"; retryable: true; site?: string[] };
85
87
  /** #215: every human disposition, in order. Operator reasons only; never task or message text. */
86
88
  audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
87
89
  disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string> };
@@ -288,6 +290,7 @@ export function registeredProjects(home = hubHome()): Project[] {
288
290
  if (!existsSync(path)) return [];
289
291
  const db = new Database(path, { readonly: true });
290
292
  try {
293
+ db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
291
294
  // #226: a registry no current release opened yet has no signature column, and this read never migrates.
292
295
  const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
293
296
  return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
@@ -433,10 +436,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
433
436
  if (op.disposition) {
434
437
  // The stop-and-archive in progress keeps its own cause; resume never runs an operation being abandoned.
435
438
  op.error ??= `a stop-and-archive of this operation is partway; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
439
+ delete op.failure;
436
440
  op.phase = "blocked"; save();
437
441
  return op;
438
442
  }
439
- op.phase = "running"; delete op.error; save();
443
+ op.phase = "running"; delete op.error; delete op.failure; save();
440
444
  step("stage");
441
445
  const target = await driver.stage(op);
442
446
  if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
@@ -494,7 +498,13 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
494
498
  await driver.commit(project, id, planned.source.instanceId!);
495
499
  const deadline = driver.now() + 30_000;
496
500
  do {
497
- live = await driver.inspect(project);
501
+ try { live = await driver.inspect(project); }
502
+ catch (error) {
503
+ if (!isDatabaseBusy(error)) throw error;
504
+ // A committed source may still be stopping. Contention is an
505
+ // unconfirmed read here, not a refusal; keep the existing bound.
506
+ live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
507
+ }
498
508
  if (live.state === "stopped") break;
499
509
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
500
510
  await driver.sleep(100);
@@ -556,6 +566,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
556
566
  releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
557
567
  } catch (error) {
558
568
  // #215: every runner error ends with the choices status shows, read from every open source, never only this one.
569
+ if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
559
570
  if (error instanceof FinalRefusal) op.final = true;
560
571
  const next = nextActionsText(op, await liveProjects(op, driver.inspect));
561
572
  op.phase = "blocked"; op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
@@ -575,7 +586,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
575
586
  ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
576
587
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
577
588
  ...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
578
- ...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
589
+ ...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
579
590
  }
580
591
 
581
592
  /** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
@@ -605,8 +616,12 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
605
616
  const state = live[planned.project.id];
606
617
  if (state?.recovery?.operationId === id && state.recovery.phase !== "released") await driver.abort(planned.project, id, planned.source.instanceId!);
607
618
  }
619
+ delete op.error; delete op.failure;
608
620
  op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
609
621
  writeOperation(id, op, home); releaseRecoveryLock(id, home);
622
+ } catch (error) {
623
+ if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
624
+ throw error;
610
625
  } finally { releaseRunner(); }
611
626
  }
612
627
 
@@ -658,6 +673,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
658
673
  (progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at };
659
674
  }
660
675
  (op.audit ??= []).push({ at, action: "fresh-session", reason, peer, projects: failed.map((p) => p.id) });
676
+ delete op.error; delete op.failure;
661
677
  op.updatedAt = at; writeOperation(id, op, home);
662
678
  return op;
663
679
  }
@@ -687,6 +703,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
687
703
  abandoned(op); // #227: in the same write as the disposition
688
704
  (op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
689
705
  // Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
706
+ delete op.failure;
690
707
  op.updatedAt = driver.now(); writeOperation(id, op, home);
691
708
  try {
692
709
  for (const { planned, live, act } of acts) {
@@ -699,12 +716,13 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
699
716
  op.updatedAt = driver.now(); writeOperation(id, op, home);
700
717
  }
701
718
  } catch (error) {
719
+ if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
702
720
  op.phase = "blocked"; op.updatedAt = driver.now();
703
721
  op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
704
722
  writeOperation(id, op, home);
705
723
  throw error;
706
724
  }
707
- delete op.error;
725
+ delete op.error; delete op.failure;
708
726
  op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
709
727
  writeOperation(id, op, home); releaseRecoveryLock(id, home);
710
728
  return op;
@@ -0,0 +1,31 @@
1
+ import type { Activity } from "../hub/activity.ts";
2
+ import { paint, stateTone, terminalText, wrap } from "./console-state.ts";
3
+
4
+ export function renderActivity(peer: string, item: Activity, columns: number, color: boolean, noThoughts = false): string[] {
5
+ if (noThoughts && item.kind === "thought") return [];
6
+ const width = Math.max(20, Math.floor(columns));
7
+ const time = new Date(item.at).toISOString().slice(11, 19);
8
+ const header = `${time} ${peer} ${item.kind} (turn ${item.turn})`;
9
+ const tone = item.kind === "turn_end" ? stateTone("idle") : "info";
10
+ return [...wrap(header, width).map(text => paint([{ text, tone }], color)),
11
+ ...wrap(terminalText(item.text), width - 4, 0).map(text => paint([{ text: ` ${text}` }], color))];
12
+ }
13
+ export interface WatchClient {
14
+ request(msg: Record<string, unknown>): Promise<any>;
15
+ onPush: (msg: any) => void;
16
+ onClose: (code: number, reason: string) => void;
17
+ close(): void;
18
+ }
19
+ /** This view only watches and reads status. No prompt, permission or command request exists here. */
20
+ export async function attachWatch(client: WatchClient, peer: string, write: (text: string) => void, options: { columns: () => number; color: boolean; noThoughts: boolean }): Promise<void> {
21
+ client.onPush = msg => {
22
+ if (msg.t !== "activity" || msg.peer !== peer || !msg.item || typeof msg.item.text !== "string" || !Number.isFinite(msg.item.at) || !Number.isSafeInteger(msg.item.turn)) return;
23
+ for (const line of renderActivity(peer, msg.item, options.columns(), options.color, options.noThoughts)) write(line);
24
+ };
25
+ const watched = await client.request({ t: "watch", peer });
26
+ if (watched.ok !== true) throw new Error(watched.error ?? "hub does not support watch; upgrade it before using this view");
27
+ const status = await client.request({ t: "status" });
28
+ if (status.ok === false) throw new Error(status.error ?? "watch connection closed");
29
+ const row = status.status?.peers?.[peer];
30
+ if (row) for (const line of wrap(`${peer}: ${row.state ?? "unknown"}; context ${row.context?.used ?? "unknown"}; q or Ctrl+C quits (approvals: ahub console)`, options.columns())) write(line);
31
+ }