@staix/agent-hub 0.12.31 → 0.12.33

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 (79) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +14 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +3 -0
  5. package/docs/agent-notes/daemon.md +13 -8
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +141 -25
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +18 -9
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  20. package/docs/verified.json +24 -16
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1296 -26
  24. package/src/adapters/acp.ts +5 -6
  25. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  26. package/src/adapters/definitions/kimi-native.ts +4 -0
  27. package/src/adapters/definitions/qwen-native.ts +24 -0
  28. package/src/adapters/definitions/qwen.ts +71 -0
  29. package/src/adapters/pi.ts +6 -2
  30. package/src/adapters/registry-types.ts +174 -0
  31. package/src/adapters/registry.ts +36 -0
  32. package/src/cli/console-state.ts +74 -5
  33. package/src/cli/console.ts +35 -2
  34. package/src/cli/help.ts +3 -1
  35. package/src/cli/identity.ts +13 -3
  36. package/src/cli/launch.ts +1 -6
  37. package/src/cli/main.ts +200 -39
  38. package/src/cli/output.ts +2 -0
  39. package/src/cli/preview.ts +8 -5
  40. package/src/cli/recovery-guidance.ts +137 -0
  41. package/src/cli/recovery-presentation-options.ts +8 -0
  42. package/src/cli/recovery-session-file.ts +18 -0
  43. package/src/cli/reset.ts +13 -1
  44. package/src/cli/terminal-recovery.ts +48 -0
  45. package/src/cli/upgrade-interactive.ts +129 -75
  46. package/src/cli/upgrade-labels.ts +30 -0
  47. package/src/cli/upgrade-presentation-data.ts +41 -0
  48. package/src/cli/upgrade-presentation-flags.ts +31 -0
  49. package/src/cli/upgrade-presentation.ts +227 -0
  50. package/src/cli/upgrade-restore-state.ts +6 -2
  51. package/src/cli/upgrade-runtime.ts +360 -199
  52. package/src/cli/upgrade.ts +278 -25
  53. package/src/hub/bus.ts +144 -14
  54. package/src/hub/cohorts.ts +2 -2
  55. package/src/hub/config-apply.ts +242 -0
  56. package/src/hub/config-reload.ts +120 -0
  57. package/src/hub/config-trust.ts +11 -2
  58. package/src/hub/control-client.ts +1 -1
  59. package/src/hub/crash.ts +8 -6
  60. package/src/hub/daemon.ts +577 -179
  61. package/src/hub/deferred-hub.ts +60 -0
  62. package/src/hub/delivery-journal.ts +6 -0
  63. package/src/hub/events.ts +2 -2
  64. package/src/hub/manager.ts +1 -1
  65. package/src/hub/peer-catalog.ts +10 -13
  66. package/src/hub/peers.ts +12 -2
  67. package/src/hub/permission-mode.ts +5 -3
  68. package/src/hub/recovery-continuity.ts +152 -0
  69. package/src/hub/recovery-store.ts +10 -4
  70. package/src/hub/restart.ts +12 -1
  71. package/src/hub/sdlc.ts +5 -1
  72. package/src/hub/settings.ts +50 -36
  73. package/src/hub/settled-roles.ts +35 -0
  74. package/src/hub/supervision.ts +3 -2
  75. package/src/hub/tasks.ts +29 -9
  76. package/src/hub/ui.ts +1 -1
  77. package/src/memory/brief.ts +6 -5
  78. package/src/pi/session.ts +23 -4
  79. package/src/ui/index.html +23 -7
@@ -0,0 +1,60 @@
1
+ import { HUB, MAX_HOP, type Envelope } from "./envelope.ts";
2
+
3
+ /** ponytail: cap recovery at 2048 unpublished hub originals; replace with a paged durable outbox if measured recovery workloads exceed it. */
4
+ export const MAX_DEFERRED_HUB = 2048;
5
+
6
+ /** Refuses admission without dropping already accepted deferred work. */
7
+ export class RecoveryDeferredHubError extends Error {
8
+ readonly code = "recovery_deferred_hub";
9
+ constructor(message = "recovery deferred hub notice limit reached; release or abort recovery before publishing more notices") {
10
+ super(message);
11
+ this.name = "RecoveryDeferredHubError";
12
+ }
13
+ }
14
+
15
+ /** Journal-less restore uses the same bounded core envelope shape as durable deferred work. */
16
+ export function validateDeferredEnvelope(value: unknown, name: string): Envelope {
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ throw new RecoveryDeferredHubError(`${name} must be an envelope`);
19
+ const env = value as Record<string, unknown>;
20
+ const text = (value: unknown, max: number) => typeof value === "string" && value.length > 0 && value.length <= max;
21
+ if (!text(env.id, 256) || !text(env.trace, 256) || env.from !== HUB ||
22
+ typeof env.body !== "string" || env.body.length > 1_000_000 ||
23
+ !Number.isSafeInteger(env.hop) || (env.hop as number) < 0 || (env.hop as number) > MAX_HOP ||
24
+ !Number.isSafeInteger(env.ts) || (env.ts as number) < 0 ||
25
+ !["chat", "task", "review", "status", "budget", "presence"].includes(env.kind as string) ||
26
+ !["important", "status", "fyi"].includes(env.priority as string) ||
27
+ (!Array.isArray(env.to) || env.to.length > 256 || !env.to.every(peer => text(peer, 256))) ||
28
+ (env.private !== undefined && typeof env.private !== "boolean"))
29
+ throw new RecoveryDeferredHubError(`${name} is invalid`);
30
+ if (env.refs !== undefined) {
31
+ if (!env.refs || typeof env.refs !== "object" || Array.isArray(env.refs)) throw new RecoveryDeferredHubError(`${name}.refs is invalid`);
32
+ const refs = env.refs as Record<string, unknown>;
33
+ for (const key of ["repo", "branch", "commit", "task"])
34
+ if (refs[key] !== undefined && !text(refs[key], 4096)) throw new RecoveryDeferredHubError(`${name}.refs.${key} is invalid`);
35
+ if (refs.paths !== undefined && (!Array.isArray(refs.paths) || refs.paths.length > 256 || !refs.paths.every(path => text(path, 4096))))
36
+ throw new RecoveryDeferredHubError(`${name}.refs.paths is invalid`);
37
+ }
38
+ return value as Envelope;
39
+ }
40
+
41
+ /** Callers keep their existing envelope validation; this leaf checks deferred-only invariants. */
42
+ export function validateDeferredHub(
43
+ value: unknown,
44
+ envelope: (value: unknown, name: string) => Envelope = validateDeferredEnvelope,
45
+ occupied: ReadonlySet<string> = new Set(),
46
+ name = "bus.deferredHub",
47
+ ): Envelope[] {
48
+ if (value === undefined) return [];
49
+ if (!Array.isArray(value) || value.length > MAX_DEFERRED_HUB)
50
+ throw new RecoveryDeferredHubError(`${name} must be an array of at most ${MAX_DEFERRED_HUB} hub envelopes`);
51
+ const ids = new Set<string>();
52
+ return value.map((item, index) => {
53
+ validateDeferredEnvelope(item, `${name}[${index}]`);
54
+ const env = envelope(item, `${name}[${index}]`);
55
+ if (env.from !== HUB) throw new RecoveryDeferredHubError(`${name}[${index}] is not hub-originated`);
56
+ if (ids.has(env.id) || occupied.has(env.id)) throw new RecoveryDeferredHubError(`${name}[${index}] repeats an accepted envelope id`);
57
+ ids.add(env.id);
58
+ return env;
59
+ });
60
+ }
@@ -6,6 +6,7 @@ import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
6
6
  import { heldDeliveryHints } from "./delivery-hints.ts";
7
7
  import { processLiveness } from "../pi/process-signature.ts";
8
8
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
9
+ import { validateDeferredHub } from "./deferred-hub.ts";
9
10
 
10
11
  export type JournalDeliveryState =
11
12
  | "queued"
@@ -167,6 +168,11 @@ function validateBus(value: unknown, name = "bus"): BusSnapshot {
167
168
  list(b.manualPaused, `${name}.manualPaused`, 4096).forEach((id) =>
168
169
  text(id, `${name}.manualPaused[]`, 256),
169
170
  );
171
+ const occupied = new Set<string>([
172
+ ...(b.seen as Envelope[]).map(env => env.id),
173
+ ...Object.values(queues).flatMap(items => (items as Envelope[]).map(env => env.id)),
174
+ ]);
175
+ validateDeferredHub(b.deferredHub, validateEnvelope, occupied, `${name}.deferredHub`);
170
176
  if (b.journal !== undefined) fail("nested journal snapshot is invalid");
171
177
  return b as unknown as BusSnapshot;
172
178
  }
package/src/hub/events.ts CHANGED
@@ -14,9 +14,9 @@ export const EVENTS_SCHEMA = 1;
14
14
  */
15
15
  export type HubEvent =
16
16
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
17
- /** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
18
17
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
19
- | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
18
+ /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
19
+ | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean; reason?: string }
20
20
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
21
21
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
22
22
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -155,7 +155,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
155
155
  return { ok: true };
156
156
  }
157
157
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
158
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
158
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
159
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
160
160
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
161
  let nominalMs = 10_000;
@@ -1,4 +1,8 @@
1
- /** Native peers the hub knows; membership checks share this list. */
1
+ import { definitionOf } from "../adapters/registry.ts";
2
+
3
+ // ponytail: older staged coordinators read these literal declarations to probe target support.
4
+ // A2 replaces that source probe with a published capability before deleting the compatibility exports.
5
+ /** Native peers the hub knows; membership checks share the registry. */
2
6
  export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
3
7
  /** Optional production peers are added explicitly; legacy defaults remain stable. */
4
8
  export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
@@ -6,25 +10,18 @@ export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
6
10
  export type KnownPeer = typeof SUPPORTED_PEERS[number];
7
11
 
8
12
  export const isKnownPeer = (value: unknown): value is KnownPeer =>
9
- typeof value === "string" && (KNOWN_PEERS as readonly string[]).includes(value);
13
+ typeof value === "string" && !!definitionOf(value) && !definitionOf(value)!.optional;
10
14
 
11
15
  export const isSupportedPeer = (value: unknown): value is KnownPeer =>
12
- typeof value === "string" && (SUPPORTED_PEERS as readonly string[]).includes(value);
16
+ typeof value === "string" && !!definitionOf(value);
13
17
 
14
18
  // Policy metadata, not a membership list. Preserve DEFAULT_ROLES' existing key order.
15
- const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
16
- claude: ["planner", "reviewer"],
17
- codex: ["implementer"],
18
- kimi: ["implementer", "verifier"],
19
- local: ["implementer", "verifier"],
20
- pi: ["implementer", "verifier"],
21
- qwen: ["implementer", "verifier"],
22
- jcode: ["implementer", "verifier"],
23
- };
19
+ const ROLE_ORDER: readonly KnownPeer[] = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode"];
24
20
 
25
21
  /** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
26
22
  export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
27
- return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
23
+ return Object.fromEntries(ROLE_ORDER.filter(peer => includeOptional || !definitionOf(peer)!.optional)
24
+ .map(peer => [peer, [...definitionOf(peer)!.roles]]));
28
25
  }
29
26
 
30
27
  /** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
package/src/hub/peers.ts CHANGED
@@ -44,18 +44,28 @@ export abstract class BasePeer implements PeerAdapter {
44
44
  onDelivery?: (receipt: DeliveryReceipt) => void;
45
45
  private _state: PeerState = "offline";
46
46
  private timer: ReturnType<typeof setTimeout> | undefined;
47
+ private nextWatchdogMs: number | undefined;
47
48
 
48
49
  constructor(
49
50
  readonly id: PeerId,
50
- protected readonly watchdogMs = DEFAULT_WATCHDOG_MS,
51
+ protected watchdogMs = DEFAULT_WATCHDOG_MS,
51
52
  ) {}
52
53
 
53
54
  get state(): PeerState {
54
55
  return this._state;
55
56
  }
56
57
 
58
+ /** Keep the active turn's inactivity bound; a changed setting belongs to the next busy turn. */
59
+ configureWatchdog(ms: number): void {
60
+ if (!Number.isFinite(ms) || ms <= 0 || ms > 2_147_483_647) throw new Error("invalid watchdog interval");
61
+ this.nextWatchdogMs = ms;
62
+ }
63
+
57
64
  protected setState(next: PeerState): void {
58
- if (next === "busy") this.touch();
65
+ if (next === "busy") {
66
+ if (this._state !== "busy" && this.nextWatchdogMs !== undefined) this.watchdogMs = this.nextWatchdogMs;
67
+ this.touch();
68
+ }
59
69
  else this.clearWatchdog();
60
70
  if (next === this._state) return;
61
71
  this._state = next;
@@ -1,12 +1,13 @@
1
1
  import { isSupportedPeer } from "./peer-catalog.ts";
2
+ import { definitionOf } from "../adapters/registry.ts";
2
3
  import { lstatSync } from "node:fs";
3
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
5
 
5
6
  /** Operator-selected approval frequency; agent sandboxes remain separate. */
6
7
  export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
7
8
  export type PermissionMode = typeof PERMISSION_MODES[number];
8
- export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
9
- export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
9
+ export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
10
+ export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
10
11
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
11
12
  export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
12
13
  if (value === undefined) return {};
@@ -29,9 +30,10 @@ export function permissionBoundary(peer: string): string {
29
30
  }
30
31
  /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
31
32
  export function permissionGrant(peer: string, mode: PermissionMode): string {
33
+ const grant = definitionOf(peer)?.permission.grants?.[mode];
34
+ if (grant !== undefined) return grant;
32
35
  if (peer === "jcode") return "no verified hub mode mapping; native policy only";
33
36
  if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
34
- if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
35
37
  if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
36
38
  if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
37
39
  if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
@@ -0,0 +1,152 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync } from "node:fs";
4
+ import { isAbsolute, join, normalize } from "node:path";
5
+ import { realPath } from "./project.ts";
6
+ import { atomicPrivateJSON, operationIdPattern } from "./recovery-store.ts";
7
+
8
+ export const RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS = 3000;
9
+ const MAX_BOOKMARK_BYTES = 16 * 1024;
10
+ const BOOKMARK = "recovery-continuity.json", LOCK = "recovery-continuity-lock.db";
11
+
12
+ export interface RecoveryContinuityBookmark {
13
+ schema: 1;
14
+ operationId: string;
15
+ projectId: string;
16
+ projectRoot: string;
17
+ at: number;
18
+ pi: { sessionId: string; sessionFile?: string; backend?: "auto" | "dgx" | "mlx"; model?: string };
19
+ }
20
+ export interface RecoveryContinuityExpected { projectId: string; projectRoot: string; operationId?: string }
21
+ export type ContinuityRetirementReason = "authenticated_session_changed" | "resume_unavailable" | "recovery_released";
22
+ export type RecoveryContinuityReading =
23
+ | { state: "absent" }
24
+ | { state: "invalid"; reason: "unavailable" | "invalid_shape" | "invalid_path" | "identity_mismatch" | "oversized" }
25
+ | { state: "ready"; bookmark: RecoveryContinuityBookmark; digest: string };
26
+
27
+ const unavailable = (): never => { throw new Error("recovery continuity metadata unavailable or invalid"); };
28
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
29
+ const keys = (value: Record<string, unknown>, allowed: readonly string[]): boolean => Object.keys(value).every(key => allowed.includes(key));
30
+ const text = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
31
+ const id = (value: unknown): value is string => text(value, 256) && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value);
32
+ const absolute = (value: unknown): value is string => text(value, 4096) && isAbsolute(value) && normalize(value) === value;
33
+ function canonicalRoot(value: unknown): string {
34
+ if (!absolute(value)) return unavailable();
35
+ const root = realPath(value);
36
+ if (!lstatSync(root).isDirectory()) return unavailable();
37
+ return root;
38
+ }
39
+ function checked(value: unknown): RecoveryContinuityBookmark {
40
+ if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", "pi"]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
41
+ const pi = value.pi;
42
+ if (!record(pi) || !keys(pi, ["sessionId", "sessionFile", "backend", "model"]) || !id(pi.sessionId) || pi.sessionFile !== undefined && !absolute(pi.sessionFile) || pi.backend !== undefined && !["auto", "dgx", "mlx"].includes(String(pi.backend)) || pi.model !== undefined && !text(pi.model, 4096)) return unavailable();
43
+ const projectRoot = canonicalRoot(value.projectRoot);
44
+ return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number,
45
+ pi: { sessionId: pi.sessionId, ...(pi.sessionFile === undefined ? {} : { sessionFile: pi.sessionFile as string }), ...(pi.backend === undefined ? {} : { backend: pi.backend as "auto" | "dgx" | "mlx" }), ...(pi.model === undefined ? {} : { model: pi.model as string }) } };
46
+ }
47
+ function stateDirectory(stateDir: string): void {
48
+ if (!absolute(stateDir)) return unavailable();
49
+ const stat = lstatSync(stateDir);
50
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable();
51
+ }
52
+ function privateFile(path: string, missing = false): void {
53
+ try {
54
+ const stat = lstatSync(path);
55
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.mode & 0o077) return unavailable();
56
+ } catch (error) { if (!missing || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
57
+ }
58
+ const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
59
+
60
+ /** Read one bounded project-local file; invalid evidence is deliberately distinct from no bookmark. */
61
+ export function readRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected): RecoveryContinuityReading {
62
+ let fd: number | undefined;
63
+ let expectedRoot: string;
64
+ try { expectedRoot = canonicalRoot(expected.projectRoot); }
65
+ catch { return { state: "invalid", reason: "identity_mismatch" }; }
66
+ try {
67
+ stateDirectory(stateDir);
68
+ const file = join(stateDir, BOOKMARK);
69
+ privateFile(file);
70
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
71
+ const stat = fstatSync(fd);
72
+ if (!stat.isFile() || stat.mode & 0o077) return { state: "invalid", reason: "invalid_path" };
73
+ if (stat.size > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
74
+ const buffer = Buffer.alloc(MAX_BOOKMARK_BYTES + 1);
75
+ let length = 0;
76
+ while (length < buffer.length) { const n = readSync(fd, buffer, length, buffer.length - length, null); if (!n) break; length += n; }
77
+ if (length > MAX_BOOKMARK_BYTES) return { state: "invalid", reason: "oversized" };
78
+ const bytes = buffer.subarray(0, length);
79
+ let bookmark: RecoveryContinuityBookmark;
80
+ try {
81
+ const raw: unknown = JSON.parse(bytes.toString("utf8"));
82
+ bookmark = checked(raw);
83
+ if (!record(raw) || raw.projectRoot !== bookmark.projectRoot) return { state: "invalid", reason: "invalid_path" };
84
+ } catch { return { state: "invalid", reason: "invalid_shape" }; }
85
+ if (!id(expected.projectId) || expected.operationId !== undefined && !operationIdPattern.test(expected.operationId) || bookmark.projectId !== expected.projectId || bookmark.projectRoot !== expectedRoot || expected.operationId !== undefined && bookmark.operationId !== expected.operationId) return { state: "invalid", reason: "identity_mismatch" };
86
+ return { state: "ready", bookmark, digest: digest(bytes) };
87
+ } catch (error) {
88
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "invalid", reason: "unavailable" };
89
+ } finally { if (fd !== undefined) closeSync(fd); }
90
+ }
91
+
92
+ /** A crash releases SQLite's lock; no lockfile ownership or stale-process inference is needed. */
93
+ function withWriter<T>(stateDir: string, action: () => T): T {
94
+ stateDirectory(stateDir);
95
+ const file = join(stateDir, LOCK);
96
+ privateFile(file, true);
97
+ for (const suffix of ["-journal", "-wal", "-shm"]) privateFile(file + suffix, true);
98
+ const db = new Database(file, { create: true });
99
+ db.run(`PRAGMA busy_timeout = ${RECOVERY_CONTINUITY_BUSY_TIMEOUT_MS}`);
100
+ try {
101
+ chmodSync(file, 0o600);
102
+ db.run("BEGIN IMMEDIATE");
103
+ try { return action(); } finally { db.run("ROLLBACK"); }
104
+ } finally { db.close(); }
105
+ }
106
+
107
+ /** Called only after the coordinator confirmed its target stopped and archived that operation's snapshot. */
108
+ export function writeRecoveryContinuity(stateDir: string, value: RecoveryContinuityBookmark): { digest: string } {
109
+ const bookmark = checked(value), bytes = Buffer.from(JSON.stringify(bookmark, null, 2) + "\n");
110
+ if (bytes.length > MAX_BOOKMARK_BYTES) return unavailable();
111
+ return withWriter(stateDir, () => {
112
+ privateFile(join(stateDir, BOOKMARK), true);
113
+ atomicPrivateJSON(join(stateDir, BOOKMARK), bookmark);
114
+ return { digest: digest(bytes) };
115
+ });
116
+ }
117
+
118
+ /** Consume only the exact bookmark whose recorded session has authenticated; stale receipts retain newer data. */
119
+ export function consumeRecoveryContinuity(stateDir: string, expected: RecoveryContinuityExpected & { operationId: string; digest: string }): boolean {
120
+ if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return false;
121
+ return withWriter(stateDir, () => {
122
+ const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
123
+ if (current.state === "absent") return false;
124
+ if (current.state === "invalid") return unavailable();
125
+ if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return false;
126
+ unlinkSync(join(stateDir, BOOKMARK));
127
+ return true;
128
+ });
129
+ }
130
+
131
+ /** Preserve superseded evidence privately; only a matching project/operation/digest may retire the active file. */
132
+ export function retireRecoveryContinuity(
133
+ stateDir: string,
134
+ expected: RecoveryContinuityExpected & { operationId: string; digest: string },
135
+ reason: ContinuityRetirementReason,
136
+ ): { retired: boolean; archive?: string } {
137
+ if (!["authenticated_session_changed", "resume_unavailable", "recovery_released"].includes(reason)) return unavailable();
138
+ if (!operationIdPattern.test(expected.operationId) || !/^[a-f0-9]{64}$/.test(expected.digest)) return { retired: false };
139
+ return withWriter(stateDir, () => {
140
+ const current = readRecoveryContinuity(stateDir, { projectId: expected.projectId, projectRoot: expected.projectRoot });
141
+ if (current.state === "absent") return { retired: false };
142
+ if (current.state === "invalid") return unavailable();
143
+ if (current.bookmark.operationId !== expected.operationId || current.digest !== expected.digest) return { retired: false };
144
+ // A unique destination preserves an earlier retirement even when the same bookmark was explicitly restored.
145
+ const archive = join(stateDir, `recovery-continuity.retired.${reason}.${current.digest}.${randomUUID()}.json`);
146
+ try { lstatSync(archive); return unavailable(); }
147
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
148
+ renameSync(join(stateDir, BOOKMARK), archive);
149
+ chmodSync(archive, 0o600);
150
+ return { retired: true, archive };
151
+ });
152
+ }
@@ -69,15 +69,21 @@ export function assertLifecycleAvailable(): void {
69
69
  * lacks dispose, refuses abort and resume on a lapsed hold and shows no next actions: then the running release is
70
70
  * named, which does all of that in-process (its resume still runs the operation's own runner).
71
71
  */
72
- export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags = ""): string {
73
- const entry = !op.sourceRoot ? undefined : coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
72
+ export type RecoveryAction = "status" | "resume" | "abort" | "dispose" | "reattach";
73
+ export function supportsAction(sourceRoot: string, action: RecoveryAction): boolean {
74
+ if (!coordinatorCurrent(sourceRoot)) return false;
75
+ if (action !== "reattach") return true;
76
+ try { return readFileSync(join(sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export async function reattachRecovery"); } catch { return false; }
77
+ }
78
+ export function recoveryCommand(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags = ""): string {
79
+ const entry = !op.sourceRoot ? undefined : supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
74
80
  const cli = !entry ? "ahub" : /^[\w./@+-]+$/.test(entry) ? `bun ${entry}` : `bun '${entry.replace(/'/g, `'\\''`)}'`;
75
81
  return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
76
82
  }
77
83
 
78
84
  /** #272: the command above as an argv for `bun`, which a screen runs instead of printing; with no coordinator of its own, this release's. */
79
- export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags: string[] = []): string[] {
80
- const entry = op.sourceRoot && coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
85
+ export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: RecoveryAction, flags: string[] = []): string[] {
86
+ const entry = op.sourceRoot && supportsAction(op.sourceRoot, action) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
81
87
  return [entry, "recovery", action, op.id, ...flags];
82
88
  }
83
89
 
@@ -2,10 +2,11 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSy
2
2
  import { createHash } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
- import type { PeerId, PeerState } from "./envelope.ts";
5
+ import type { Envelope, PeerId, PeerState } from "./envelope.ts";
6
6
  import { MAX_HOP } from "./envelope.ts";
7
7
  import { validateJournalSnapshot } from "./delivery-journal.ts";
8
8
  import { atomicPrivateJSON } from "./recovery-store.ts";
9
+ import { RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
9
10
 
10
11
  export const RESTART_SCHEMA_VERSION = 1;
11
12
 
@@ -75,6 +76,16 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
75
76
  const bus = s.bus as Partial<BusSnapshot>;
76
77
  if (bus.schemaVersion !== 1 || !bus.queues || typeof bus.queues !== "object" || !bus.prefaces || typeof bus.prefaces !== "object" || !Array.isArray(bus.seen) || !bus.attempts || typeof bus.attempts !== "object" || !Array.isArray(bus.withdrawn)) return undefined;
77
78
  if (!Object.keys(bus.queues).every(peerId) || !Object.keys(bus.prefaces).every(peerId) || !Object.values(bus.queues).every((queue) => Array.isArray(queue) && queue.every((e) => isEnvelope(e))) || !Object.values(bus.prefaces).every((e) => isEnvelope(e)) || !bus.seen.every((e) => isEnvelope(e, true)) || !bus.withdrawn.every((id) => typeof id === "string" && id.length > 0) || !Object.values(bus.attempts).every((n) => Number.isSafeInteger(n) && n > 0)) return undefined;
79
+ try {
80
+ const occupied = new Set<string>([
81
+ ...bus.seen.map(env => env.id),
82
+ ...Object.values(bus.queues).flatMap(queue => queue.map(env => env.id)),
83
+ ]);
84
+ validateDeferredHub(bus.deferredHub, (value, name) => {
85
+ if (!isEnvelope(value)) throw new Error(`${name} is invalid`);
86
+ return value as Envelope;
87
+ }, occupied);
88
+ } catch (error) { if (error instanceof RecoveryDeferredHubError) throw error; return undefined; }
78
89
  if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
79
90
  if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
80
91
  if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
package/src/hub/sdlc.ts CHANGED
@@ -3,6 +3,7 @@ import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { childEnv, stopOwnedProcess } from "./child-process.ts";
6
+ import { hostExecutable } from "./host-executable.ts";
6
7
 
7
8
  export const SDLC_STAGES = ["spec", "plan", "implement", "review", "verify", "merge", "release"] as const;
8
9
  export type SdlcBaseStage = typeof SDLC_STAGES[number];
@@ -219,7 +220,10 @@ export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutpu
219
220
  /** Read-only, fixed argv. Output and time are bounded; diagnostics never expose credentials or remote text. */
220
221
  export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
221
222
  const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
222
- const child = spawn("gh", args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
223
+ let child: ReturnType<typeof spawn>;
224
+ try {
225
+ child = spawn(hostExecutable("gh", cwd ?? process.cwd()), args, { ...(cwd ? { cwd } : {}), env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
226
+ } catch { resolve({ code: 1, stdout: "" }); return; }
223
227
  let stdout = "", failed = false;
224
228
  const stop = () => { failed = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
225
229
  const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));