@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.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- 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
|
-
|
|
335
|
-
|
|
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
|
};
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -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 (
|
|
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 &&
|
|
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 &&
|
|
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 (
|
|
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 ||
|
|
183
|
+
return row?.instanceId === instanceId && (row.pid === null || processLiveness(row.pid, row.pidSignature) !== "gone");
|
|
189
184
|
} finally { registry.close(); }
|
|
190
185
|
}
|
|
191
186
|
|
package/src/hub/manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 &&
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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 () => {
|
package/src/hub/registry.ts
CHANGED
|
@@ -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 ||
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 = ?")
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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 {
|