@staix/agent-hub 0.12.20 → 0.12.22

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 (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
5
  import type { Envelope } from "./envelope.ts";
6
+ import { processLiveness } from "../pi/process-signature.ts";
6
7
 
7
8
  export type JournalDeliveryState =
8
9
  | "queued"
@@ -331,13 +332,12 @@ export class DeliveryJournal {
331
332
  Number.isInteger(status.pid) &&
332
333
  Number(status.pid) > 0
333
334
  ) {
334
- try {
335
- process.kill(Number(status.pid), 0);
335
+ // #226: a signed manifest whose pid was reused is gone; with a signature, an owner that cannot be identified is
336
+ // refused like a live one. Unsigned, a pid that cannot be probed (EPERM) never refused here, as before.
337
+ const signature = typeof status.pidSignature === "string" ? status.pidSignature : undefined;
338
+ const owner = processLiveness(Number(status.pid), signature);
339
+ if (owner === "live" || (signature && owner === "unknown"))
336
340
  throw new Error("delivery journal is owned by a live instance");
337
- } catch (e) {
338
- if (e instanceof Error && e.message.includes("owned by a live"))
339
- throw e;
340
- }
341
341
  }
342
342
  } catch (e) {
343
343
  if (e instanceof Error && e.message.includes("owned by a live")) throw e;
package/src/hub/events.ts CHANGED
@@ -11,6 +11,7 @@ export const EVENTS_SCHEMA = 1;
11
11
  * ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
12
12
  */
13
13
  export type HubEvent =
14
+ | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
14
15
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
15
16
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
16
17
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -39,7 +40,7 @@ export type HubEvent =
39
40
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
40
41
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
41
42
  | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
42
- | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
43
+ | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean; reason?: string }
43
44
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
44
45
  /** A PII screen verdict (issue #198): what was screened and the closed label, source and category, never the text. */
45
46
  | ({ type: "pii_screen" } & ScreenRecord)
@@ -65,7 +66,7 @@ function cutShort(file: string): boolean {
65
66
  }
66
67
 
67
68
  /** An appender that never throws: the state dir can vanish under a running hub, and telemetry must not take it down. */
68
- export function eventLog(file: string): (e: HubEvent) => void {
69
+ export function eventLog(file: string, onFailure?: (e: HubEvent) => void): (e: HubEvent) => void {
69
70
  let first = true;
70
71
  return (e) => {
71
72
  try {
@@ -74,6 +75,7 @@ export function eventLog(file: string): (e: HubEvent) => void {
74
75
  first = false;
75
76
  appendFileSync(file, `${lead}${JSON.stringify({ v: EVENTS_SCHEMA, at: new Date().toISOString(), ...e })}\n`);
76
77
  } catch {
78
+ try { onFailure?.(e); } catch { /* telemetry failure reporting is optional too */ }
77
79
  // the state dir is gone; the watchdog is stopping the hub
78
80
  }
79
81
  };
@@ -8,6 +8,7 @@ import { startDaemon } from "./daemon.ts";
8
8
  import { Registry, type Project } from "./registry.ts";
9
9
  import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
10
10
  import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
11
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
11
12
 
12
13
  export type ProjectInspection = {
13
14
  state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
@@ -15,19 +16,12 @@ export type ProjectInspection = {
15
16
  error?: string;
16
17
  };
17
18
 
18
- /** Alive, gone (`false`), or uncertain (`undefined`: no valid pid, or no permission to ask). */
19
- export function processAlive(pid: unknown): boolean | undefined {
20
- if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
21
- try { process.kill(pid, 0); return true; }
22
- catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined; }
23
- }
24
-
25
19
  /** A port or PID alone is never evidence that the selected project's hub is alive. */
26
20
  export async function inspectProject(project: Project): Promise<ProjectInspection> {
27
21
  if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
28
22
  const control = readControl(project.stateDir);
29
23
  if (control) {
30
- if (processAlive(control.pid) === false) return { state: "stopped" };
24
+ if (processLiveness(control.pid, control.pidSignature) === "gone") return { state: "stopped" };
31
25
  if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
32
26
  return { state: "incompatible", error: `hub protocol ${control.protocol}; stop with its matching CLI before upgrading` };
33
27
  }
@@ -43,7 +37,7 @@ export async function inspectProject(project: Project): Promise<ProjectInspectio
43
37
  if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
44
38
  return { state: "unavailable", error: "authenticated hub identity does not match registration" };
45
39
  }
46
- if (project.instanceId && project.instanceId !== response.status.instanceId && processAlive(project.pid) !== false) {
40
+ if (project.instanceId && project.instanceId !== response.status.instanceId && processLiveness(project.pid, project.pidSignature) !== "gone") {
47
41
  return { state: "unavailable", error: "daemon instance differs from the registry claim" };
48
42
  }
49
43
  return response.status.stopping
@@ -53,10 +47,11 @@ export async function inspectProject(project: Project): Promise<ProjectInspectio
53
47
  return { state: (error as { code?: number }).code === 4426 ? "incompatible" : "unavailable", error: (error as Error).message };
54
48
  } finally { client?.close(); }
55
49
  }
56
- if (project.instanceId && processAlive(project.pid) !== false) return { state: "starting", error: "daemon owns a startup or shutdown claim" };
50
+ if (project.instanceId && processLiveness(project.pid, project.pidSignature) !== "gone") return { state: "starting", error: "daemon owns a startup or shutdown claim" };
57
51
  try {
52
+ // Written only by daemons up to 0.12.20 (#226): an unsigned legacy record, read as those releases read it.
58
53
  const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
59
- if (processAlive(pid) !== false) return { state: "unavailable", error: "state has a live or uncertain owner but no usable control connection" };
54
+ if (processLiveness(pid) !== "gone") return { state: "unavailable", error: "state has a live or uncertain owner but no usable control connection" };
60
55
  } catch { /* no legacy PID file */ }
61
56
  return { state: "stopped" };
62
57
  }
@@ -109,7 +104,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
109
104
  const before = await inspectProject(registry.get(project.id) ?? project);
110
105
  if (before.state === "running" || before.state === "starting") return;
111
106
  if (before.state !== "stopped") throw new Error(before.error ?? before.state);
112
- claimed = registry.claim(project.id, instanceId, process.pid);
107
+ claimed = registry.claim(project.id, instanceId, process.pid, processSignature(process.pid));
113
108
  if (!claimed) return;
114
109
  let base = registry.get(project.id)!.basePort;
115
110
  for (let attempt = 0; attempt < 100; attempt++) {
@@ -185,7 +180,7 @@ function claimHeld(id: string, instanceId: string): boolean {
185
180
  const registry = new Registry();
186
181
  try {
187
182
  const row = registry.get(id);
188
- return row?.instanceId === instanceId && (row.pid === null || processAlive(row.pid) !== false);
183
+ return row?.instanceId === instanceId && (row.pid === null || processLiveness(row.pid, row.pidSignature) !== "gone");
189
184
  } finally { registry.close(); }
190
185
  }
191
186
 
@@ -9,6 +9,7 @@ import { hubHome } from "./project.ts";
9
9
  import { startDashboard } from "./ui.ts";
10
10
  import { projectChain } from "../memory/recall.ts";
11
11
  import type { ProjectInspection } from "./lifecycle.ts";
12
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
12
13
 
13
14
  export type ProjectRecord = Project;
14
15
  export type Lifecycle = {
@@ -18,7 +19,7 @@ export type Lifecycle = {
18
19
  };
19
20
  export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number };
20
21
  type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
21
- type Manifest = { port: number; protocol: number; instanceId: string; pid: number };
22
+ type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
22
23
  const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
23
24
  const CONTROL_TIMEOUT = 3000;
24
25
  // A current CLI may authenticate the previous protocol-9 manager while
@@ -41,20 +42,20 @@ async function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
41
42
  timer = setTimeout(() => reject(new Error("completion was not confirmed; refresh before retrying")), ms);
42
43
  })]); } finally { clearTimeout(timer); }
43
44
  }
44
- function alive(pid: number): boolean | undefined {
45
- if (!Number.isSafeInteger(pid) || pid < 1) return undefined;
46
- try { process.kill(pid, 0); return true; }
47
- catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined; }
48
- }
49
- function claimOwner(path: string, instanceId: string): Database {
45
+ function claimOwner(path: string, instanceId: string, signature: string | undefined): Database {
50
46
  const db = new Database(path, { create: true });
51
47
  try {
52
48
  db.run("PRAGMA busy_timeout = 5000");
53
- db.run("CREATE TABLE IF NOT EXISTS owner (slot INTEGER PRIMARY KEY CHECK (slot = 1), instance_id TEXT NOT NULL, pid INTEGER NOT NULL)");
49
+ db.run("CREATE TABLE IF NOT EXISTS owner (slot INTEGER PRIMARY KEY CHECK (slot = 1), instance_id TEXT NOT NULL, pid INTEGER NOT NULL, signature TEXT)");
50
+ if (!(db.query("PRAGMA table_info(owner)").all() as { name: string }[]).some((c) => c.name === "signature")) {
51
+ try { db.run("ALTER TABLE owner ADD COLUMN signature TEXT"); }
52
+ catch (error) { if (!/duplicate column/i.test((error as Error).message)) throw error; }
53
+ }
54
54
  db.transaction(() => {
55
- const row = db.query("SELECT pid FROM owner WHERE slot = 1").get() as { pid: number } | null;
56
- if (row && alive(row.pid) !== false) throw new Error("manager has a live or uncertain owner");
57
- db.query("INSERT OR REPLACE INTO owner (slot, instance_id, pid) VALUES (1, ?, ?)").run(instanceId, process.pid);
55
+ // #226: an older manager's INSERT OR REPLACE leaves the signature NULL, so its row is judged by the pid alone.
56
+ const row = db.query("SELECT pid, signature FROM owner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
57
+ if (row && processLiveness(row.pid, row.signature) !== "gone") throw new Error("manager has a live or uncertain owner");
58
+ db.query("INSERT OR REPLACE INTO owner (slot, instance_id, pid, signature) VALUES (1, ?, ?, ?)").run(instanceId, process.pid, signature ?? null);
58
59
  }).immediate();
59
60
  return db;
60
61
  } catch (error) { db.close(); throw error; }
@@ -83,6 +84,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
83
84
  const f = files(home);
84
85
  mkdirSync(f.dir, { recursive: true, mode: 0o700 });
85
86
  const token = randomBytes(32).toString("hex"), instanceId = randomUUID();
87
+ const pidSignature = processSignature(process.pid); // #226: the owner row and manifest say which process holds this pid
86
88
  let owner: Database | undefined;
87
89
  let server: ReturnType<typeof Bun.serve> | undefined;
88
90
  let dashboard: ReturnType<typeof startDashboard> | undefined;
@@ -110,7 +112,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
110
112
  cleanup(); resolveStopped();
111
113
  };
112
114
  try {
113
- owner = claimOwner(f.owner, instanceId);
115
+ owner = claimOwner(f.owner, instanceId, pidSignature);
114
116
  const aliases = new Map<string, { at: number; names: string[] }>();
115
117
  const list = async () => {
116
118
  const projects = registry.list();
@@ -172,7 +174,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
172
174
  writeFileSync(f.token, token, { mode: 0o600 });
173
175
  chmodSync(f.token, 0o600);
174
176
  const temp = `${f.status}.${instanceId}.tmp`;
175
- writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL, instanceId, pid: process.pid }));
177
+ writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL, instanceId, pid: process.pid, ...(pidSignature ? { pidSignature } : {}) }));
176
178
  renameSync(temp, f.status);
177
179
  // The manager outlives whoever opened it; a deleted AGENTHUB_HOME (a test suite's temp
178
180
  // home, issue #56) must not leave it serving a registry that no longer exists.
@@ -209,7 +211,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
209
211
  try { return (await managerRequest(home, "/open")).body.url; }
210
212
  catch (error) {
211
213
  const old = readManifest(home);
212
- if (old && alive(old.pid) !== false) throw error;
214
+ if (old && processLiveness(old.pid, old.pidSignature) !== "gone") throw error;
213
215
  // A definitely dead manager can be recovered by its ownership claim.
214
216
  }
215
217
  const f = files(home);
package/src/hub/peers.ts CHANGED
@@ -18,7 +18,7 @@ export interface PeerAdapter {
18
18
  /** Optional: feed envelopes into the turn that is running now. Only called while `state === "busy"`. */
19
19
  steer?(envs: Envelope[], deliveryId?: string): Promise<void>;
20
20
  start(): Promise<void>;
21
- stop(): Promise<void>;
21
+ stop(reason?: string): Promise<void>;
22
22
  /** Set by the bus. The peer said something worth sharing. */
23
23
  onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
24
24
  /** Set by the bus. */
@@ -84,5 +84,5 @@ export abstract class BasePeer implements PeerAdapter {
84
84
 
85
85
  abstract deliver(envs: Envelope[], deliveryId?: string): Promise<void>;
86
86
  abstract start(): Promise<void>;
87
- abstract stop(): Promise<void>;
87
+ abstract stop(reason?: string): Promise<void>;
88
88
  }
@@ -0,0 +1,44 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { isAbsolute, relative, sep } from "node:path";
3
+
4
+ /** Operator-selected approval frequency; agent sandboxes remain separate. */
5
+ export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
6
+ export type PermissionMode = typeof PERMISSION_MODES[number];
7
+ export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
8
+ export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
9
+ export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
10
+ if (value === undefined) return {};
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
12
+ const result: Record<string, PermissionMode> = {};
13
+ for (const [peer, mode] of Object.entries(value)) {
14
+ if (!["claude", "codex", "kimi", "pi", "local"].includes(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
15
+ if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
16
+ result[peer] = mode;
17
+ }
18
+ return result;
19
+ }
20
+ export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
21
+ /** Native agent policy files are never included in scoped automatic file grants. */
22
+ export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".pi", ".mcp.json"]);
23
+ /**
24
+ * One spelling per name, as a case-insensitive disk sees it: it opens `.mcp.j\u017Fon` (long s) as `.mcp.json`, and
25
+ * HFS+ also ignores the default-ignorable code points (a zero-width joiner inside `.codex` is still `.codex`).
26
+ */
27
+ export const foldSegment = (segment: string): string => segment.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").toLowerCase().toUpperCase().toLowerCase();
28
+ /** The hub's and git's own directories: writing them changes what runs outside any agent's control. */
29
+ export const HUB_WRITE_SEGMENTS: ReadonlySet<string> = new Set([".git", ".agenthub"]);
30
+ export const isHubWritePath = (path: string): boolean => path.split(/[\\/]/).some(segment => HUB_WRITE_SEGMENTS.has(foldSegment(segment)));
31
+ export const isAgentConfigPath = (path: string): boolean => path.split(/[\\/]/).some(segment => AGENT_CONFIG_SEGMENTS.has(foldSegment(segment)));
32
+ /** A file with a second name somewhere else: a grant for this name would write that one too. */
33
+ export function hardLinked(path: string): boolean {
34
+ try { const st = lstatSync(path); return st.isFile() && st.nlink > 1; } catch { return false; } // a file that does not exist yet has no other name
35
+ }
36
+ /**
37
+ * Whether a scoped automatic write or edit grant may cover `path` (canonical) in the project at `root` (canonical):
38
+ * inside the project, outside every agent's configuration, judged by the path within the project (a project that
39
+ * itself lives under `.claude/worktrees/` is still a project), and not a hard link.
40
+ */
41
+ export function grantablePath(root: string, path: string): boolean {
42
+ const inside = relative(root, path);
43
+ return !!inside && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) && !isAgentConfigPath(inside) && !isHubWritePath(inside) && !hardLinked(path);
44
+ }
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { Database } from "bun:sqlite";
5
5
  import { hubHome } from "./project.ts";
6
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
6
7
 
7
8
  export const operationIdPattern = /^[a-f0-9-]{36}$/;
8
9
  export function operationPath(id: string, home = hubHome()): string {
@@ -73,6 +74,12 @@ export function recoveryCommand(op: { id: string; sourceRoot?: string }, action:
73
74
  return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
74
75
  }
75
76
 
77
+ /** #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. */
78
+ export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags: string[] = []): string[] {
79
+ const entry = op.sourceRoot && coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
80
+ return [entry, "recovery", action, op.id, ...flags];
81
+ }
82
+
76
83
  /**
77
84
  * Whether a coordinator has the #215 recovery commands (dispose, re-preparation, next actions).
78
85
  * ponytail: a text sniff of its upgrade.ts; a rename or re-export reads as an older coordinator (the running release is
@@ -90,9 +97,13 @@ export function activeOperation(owner: string, home = hubHome()): string {
90
97
  return `recovery operation ${owner} is active; ${recoveryCommand(op, "status")} lists what to do next`;
91
98
  }
92
99
 
100
+ /** #226: the runner row's signature column, added to a runner.db an older release created; older runners leave it NULL. */
101
+ const signed = (db: Database) => (db.query("PRAGMA table_info(runner)").all() as { name: string }[]).some((c) => c.name === "signature");
102
+
93
103
  /**
94
104
  * The pid of the runner that holds the operation now, read without claiming it (#215 status): undefined when none does,
95
- * "unknown" when the record cannot be read (unreadable, or still locked after the busy timeout), never "none" then.
105
+ * "unknown" when the record cannot be read (unreadable, or still locked after the busy timeout) or its owner cannot be
106
+ * identified, never "none" then. A row whose signature belongs to another process now is no runner (#226).
96
107
  */
97
108
  export function recoveryRunner(id: string, home = hubHome()): number | "unknown" | undefined {
98
109
  const path = `${operationPath(id, home)}.runner.db`;
@@ -103,14 +114,46 @@ export function recoveryRunner(id: string, home = hubHome()): number | "unknown"
103
114
  db.run("PRAGMA busy_timeout = 3000"); // a claim in progress must not read as no runner
104
115
  // A crash between creating the file and its table leaves no runner; claimRunner creates the table and claims.
105
116
  if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runner'").get()) return undefined;
106
- const row = db.query("SELECT pid FROM runner WHERE slot = 1").get() as { pid: number } | null;
117
+ const row = db.query(`SELECT pid${signed(db) ? ", signature" : ""} FROM runner WHERE slot = 1`).get() as { pid: number; signature?: string | null } | null;
107
118
  if (!row) return undefined;
108
119
  if (!Number.isSafeInteger(row.pid) || row.pid < 1) return "unknown";
109
- try { process.kill(row.pid, 0); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return undefined; }
110
- return row.pid;
120
+ const state = processLiveness(row.pid, row.signature);
121
+ return state === "gone" ? undefined : state === "live" ? row.pid : "unknown";
111
122
  } catch { return "unknown"; } finally { db?.close(); }
112
123
  }
113
124
 
125
+ /**
126
+ * #272: the pid of the runner that holds `id`, only when its claim carries a process signature that still matches.
127
+ * `recoveryRunner` answers a claim without one (a coordinator of 0.12.20 or older, or a `ps` that failed at claim
128
+ * time) from its bare pid, which is enough to wait for it and never enough to signal it.
129
+ */
130
+ export function signedRunner(id: string, home = hubHome()): number | undefined {
131
+ const path = `${operationPath(id, home)}.runner.db`;
132
+ if (!existsSync(path)) return undefined;
133
+ let db: Database | undefined;
134
+ try {
135
+ db = new Database(path, { readonly: true });
136
+ db.run("PRAGMA busy_timeout = 3000");
137
+ if (!signed(db)) return undefined;
138
+ const row = db.query("SELECT pid, signature FROM runner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
139
+ if (!row?.signature || !Number.isSafeInteger(row.pid) || row.pid < 1) return undefined;
140
+ return processSignature(row.pid) === row.signature ? row.pid : undefined;
141
+ } catch { return undefined; } finally { db?.close(); }
142
+ }
143
+
144
+ /** #272: stop that verified runner and wait until no runner holds the operation. False when one still does, or cannot be verified. */
145
+ export async function stopSignedRunner(id: string, home = hubHome(), waitMs = 5000): Promise<boolean> {
146
+ if (recoveryRunner(id, home) === undefined) return true;
147
+ const pid = signedRunner(id, home);
148
+ if (pid === undefined) return false;
149
+ try { process.kill(pid, "SIGTERM"); } catch { /* gone meanwhile */ }
150
+ for (const deadline = Date.now() + waitMs; Date.now() < deadline;) {
151
+ if (recoveryRunner(id, home) === undefined) return true;
152
+ await new Promise((resolve) => setTimeout(resolve, 100));
153
+ }
154
+ return false;
155
+ }
156
+
114
157
  /** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
115
158
  export function claimRunner(id: string, home = hubHome()): () => void {
116
159
  const path = `${operationPath(id, home)}.runner.db`;
@@ -119,15 +162,22 @@ export function claimRunner(id: string, home = hubHome()): () => void {
119
162
  const nonce = randomUUID();
120
163
  try {
121
164
  db.run("PRAGMA busy_timeout = 3000");
122
- db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL)");
165
+ db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL, signature TEXT)");
166
+ if (!signed(db)) {
167
+ try { db.run("ALTER TABLE runner ADD COLUMN signature TEXT"); }
168
+ catch (error) { if (!/duplicate column/i.test((error as Error).message)) throw error; } // a concurrent claim added it
169
+ }
170
+ const signature = processSignature(process.pid) ?? null;
123
171
  db.transaction(() => {
124
- const row = db.query("SELECT pid FROM runner WHERE slot = 1").get() as { pid: number } | null;
172
+ const row = db.query("SELECT pid, signature FROM runner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
125
173
  if (row) {
126
174
  if (!Number.isSafeInteger(row.pid) || row.pid < 1) throw new Error("invalid recovery runner ownership");
127
- try { process.kill(row.pid, 0); throw new Error(`recovery runner ${row.pid} is still alive`); }
128
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
175
+ const state = processLiveness(row.pid, row.signature);
176
+ if (state === "live") throw new Error(`recovery runner ${row.pid} is still alive`);
177
+ if (state === "unknown") throw new Error(`recovery runner ${row.pid} cannot be identified (its process exists but cannot be inspected); refusing to replace it`);
129
178
  }
130
- db.query("INSERT OR REPLACE INTO runner (slot, pid, nonce) VALUES (1, ?, ?)").run(process.pid, nonce);
179
+ // An older runner rewrites the whole row (INSERT OR REPLACE), so its claim never inherits this signature.
180
+ db.query("INSERT OR REPLACE INTO runner (slot, pid, nonce, signature) VALUES (1, ?, ?, ?)").run(process.pid, nonce, signature);
131
181
  }).immediate();
132
182
  } catch (error) { db.close(); throw error; }
133
183
  return () => {
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { canonicalPath, hubHome, projectRoot } from "./project.ts";
6
+ import { processLiveness } from "../pi/process-signature.ts";
6
7
 
7
8
  const BASE_PORT = 4600;
8
9
  const STRIDE = 10;
@@ -19,22 +20,27 @@ export interface Project {
19
20
  basePort: number;
20
21
  instanceId: string | null;
21
22
  pid: number | null;
23
+ /** #226: the claimant's processSignature; absent for a claim written by 0.12.20 or older (judged by its pid alone). */
24
+ pidSignature?: string | null;
25
+ }
26
+
27
+ /**
28
+ * #226: `pid_signature` holds "<instance id> <signature>". An older daemon's claim rewrites instance_id and pid but not
29
+ * this column, so a signature counts only for the instance that wrote it: under another instance the claim is unsigned.
30
+ */
31
+ export const PROJECT_COLUMNS = "id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid";
32
+ export function projectFromRow(raw: Record<string, unknown>): Project {
33
+ const { pid_signature: signed, ...row } = raw;
34
+ const project = { ...row } as unknown as Project;
35
+ const prefix = `${project.instanceId} `;
36
+ if (project.instanceId && typeof signed === "string" && signed.startsWith(prefix) && signed.length > prefix.length) project.pidSignature = signed.slice(prefix.length);
37
+ return project;
22
38
  }
23
39
 
24
40
  const idFor = (root: string) => `p_${createHash("sha256").update(root).digest("hex").slice(0, 24)}`;
25
41
  // Preserve the original identifier of missing legacy checkouts as a reservation.
26
42
  const legacyRoot = (path: string) => { try { return realpathSync(path); } catch { return resolve(path); } };
27
43
  const canonicalState = canonicalPath;
28
- const alive = (pid: number): boolean | undefined => {
29
- try {
30
- process.kill(pid, 0);
31
- return true;
32
- } catch (error) {
33
- const code = (error as NodeJS.ErrnoException).code;
34
- if (code === "ESRCH") return false;
35
- return undefined;
36
- }
37
- };
38
44
 
39
45
  export class Registry {
40
46
  readonly path: string;
@@ -56,6 +62,11 @@ export class Registry {
56
62
  base_port INTEGER NOT NULL UNIQUE, instance_id TEXT, pid INTEGER, claimed_at INTEGER
57
63
  )`);
58
64
  this.db.run("CREATE TABLE IF NOT EXISTS registry_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
65
+ const columns = this.db.query("PRAGMA table_info(projects)").all() as { name: string }[];
66
+ if (!columns.some((c) => c.name === "pid_signature")) {
67
+ try { this.db.run("ALTER TABLE projects ADD COLUMN pid_signature TEXT"); }
68
+ catch (error) { if (!/duplicate column/i.test((error as Error).message)) throw error; } // a concurrent open added it
69
+ }
59
70
  this.importLegacy();
60
71
  }
61
72
 
@@ -70,7 +81,7 @@ export class Registry {
70
81
  if (existing) {
71
82
  if (existing.root !== canonicalRoot) throw new Error(`registry id collision for ${canonicalRoot}`);
72
83
  if (explicitState && existing.stateDir !== state) {
73
- if (existing.instanceId && (existing.pid === null || alive(existing.pid) !== false)) throw new Error(`cannot relocate live or uncertain project ${id}`);
84
+ if (existing.instanceId && (existing.pid === null || processLiveness(existing.pid, existing.pidSignature) !== "gone")) throw new Error(`cannot relocate live or uncertain project ${id}`);
74
85
  this.db.query("UPDATE projects SET state_dir = ? WHERE id = ?").run(state, id);
75
86
  }
76
87
  return;
@@ -91,7 +102,7 @@ export class Registry {
91
102
  }
92
103
 
93
104
  list(): Project[] {
94
- return this.db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects ORDER BY root").all() as Project[];
105
+ return (this.db.query(`SELECT ${PROJECT_COLUMNS}, pid_signature FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
95
106
  }
96
107
 
97
108
  allocate(root: string): number {
@@ -110,17 +121,18 @@ export class Registry {
110
121
  return base;
111
122
  }
112
123
 
113
- claim(id: string, instanceId: string, pid: number): boolean {
124
+ /** `signature`: the claimant's processSignature (#226); without one the claim is judged by its pid alone. */
125
+ claim(id: string, instanceId: string, pid: number, signature?: string): boolean {
114
126
  let claimed = false;
115
127
  const tx = this.db.transaction(() => {
116
128
  const project = this.row(id);
117
129
  if (!project) return;
118
130
  if (project.instanceId && project.instanceId !== instanceId) {
119
131
  if (project.pid === null) return;
120
- const state = alive(project.pid);
121
- if (state !== false) return;
132
+ if (processLiveness(project.pid, project.pidSignature) !== "gone") return;
122
133
  }
123
- this.db.query("UPDATE projects SET instance_id = ?, pid = ?, claimed_at = ? WHERE id = ?").run(instanceId, pid, Date.now(), id);
134
+ this.db.query("UPDATE projects SET instance_id = ?, pid = ?, pid_signature = ?, claimed_at = ? WHERE id = ?")
135
+ .run(instanceId, pid, signature ? `${instanceId} ${signature}` : null, Date.now(), id);
124
136
  claimed = true;
125
137
  });
126
138
  runTx(tx as Tx);
@@ -128,7 +140,7 @@ export class Registry {
128
140
  }
129
141
 
130
142
  release(id: string, instanceId: string): void {
131
- this.db.query("UPDATE projects SET instance_id = NULL, pid = NULL, claimed_at = NULL WHERE id = ? AND instance_id = ?").run(id, instanceId);
143
+ this.db.query("UPDATE projects SET instance_id = NULL, pid = NULL, pid_signature = NULL, claimed_at = NULL WHERE id = ? AND instance_id = ?").run(id, instanceId);
132
144
  }
133
145
 
134
146
  remove(id: string): void {
@@ -136,16 +148,17 @@ export class Registry {
136
148
  const project = this.row(id);
137
149
  if (!project) return;
138
150
  if (project.instanceId) {
139
- if (project.pid === null || alive(project.pid) !== false) throw new Error(`project ${id} has a live or uncertain claim`);
151
+ if (project.pid === null || processLiveness(project.pid, project.pidSignature) !== "gone") throw new Error(`project ${id} has a live or uncertain claim`);
140
152
  } else if (existsSync(join(project.stateDir, "status.json")) || existsSync(join(project.stateDir, "control-token"))) {
141
- let pid: number | undefined;
153
+ let pid: number | undefined, signature: string | undefined;
142
154
  try {
143
- const status = JSON.parse(readFileSync(join(project.stateDir, "status.json"), "utf8")) as { pid?: number };
155
+ const status = JSON.parse(readFileSync(join(project.stateDir, "status.json"), "utf8")) as { pid?: number; pidSignature?: unknown };
144
156
  pid = typeof status.pid === "number" ? status.pid : undefined;
157
+ signature = typeof status.pidSignature === "string" ? status.pidSignature : undefined;
145
158
  } catch {
146
159
  // A state directory without a readable owner marker is uncertain.
147
160
  }
148
- if (pid === undefined || alive(pid) !== false) throw new Error(`project ${id} has a live or uncertain legacy state`);
161
+ if (pid === undefined || processLiveness(pid, signature) !== "gone") throw new Error(`project ${id} has a live or uncertain legacy state`);
149
162
  }
150
163
  this.db.query("DELETE FROM projects WHERE id = ?").run(id);
151
164
  });
@@ -157,7 +170,8 @@ export class Registry {
157
170
  }
158
171
 
159
172
  private row(id: string): Project | null {
160
- return this.db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects WHERE id = ?").get(id) as Project | null;
173
+ const raw = this.db.query(`SELECT ${PROJECT_COLUMNS}, pid_signature FROM projects WHERE id = ?`).get(id) as Record<string, unknown> | null;
174
+ return raw ? projectFromRow(raw) : null;
161
175
  }
162
176
 
163
177
  private nextBase(after = BASE_PORT - STRIDE): number {