@staix/agent-hub 0.12.21 → 0.12.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
|
@@ -4,6 +4,7 @@ import { Database } from "bun:sqlite";
|
|
|
4
4
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
5
|
import type { Envelope } from "./envelope.ts";
|
|
6
6
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
7
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
8
|
|
|
8
9
|
export type JournalDeliveryState =
|
|
9
10
|
| "queued"
|
|
@@ -239,10 +240,10 @@ export class DeliveryJournal {
|
|
|
239
240
|
this.operationId = options.operationId;
|
|
240
241
|
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
241
242
|
this.db = new Database(options.file, { create: true });
|
|
243
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
242
244
|
this.harden();
|
|
243
245
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
244
246
|
this.db.run("PRAGMA synchronous = FULL");
|
|
245
|
-
this.db.run("PRAGMA busy_timeout = 3000");
|
|
246
247
|
this.createSchema();
|
|
247
248
|
const meta = this.readMeta();
|
|
248
249
|
if (!meta)
|
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
package/src/hub/events.ts
CHANGED
|
@@ -12,6 +12,8 @@ export const EVENTS_SCHEMA = 1;
|
|
|
12
12
|
*/
|
|
13
13
|
export type HubEvent =
|
|
14
14
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
15
|
+
/** 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. */
|
|
16
|
+
| { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
|
|
15
17
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
16
18
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
17
19
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
|
@@ -66,7 +68,7 @@ function cutShort(file: string): boolean {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/** An appender that never throws: the state dir can vanish under a running hub, and telemetry must not take it down. */
|
|
69
|
-
export function eventLog(file: string): (e: HubEvent) => void {
|
|
71
|
+
export function eventLog(file: string, onFailure?: (e: HubEvent) => void): (e: HubEvent) => void {
|
|
70
72
|
let first = true;
|
|
71
73
|
return (e) => {
|
|
72
74
|
try {
|
|
@@ -75,6 +77,7 @@ export function eventLog(file: string): (e: HubEvent) => void {
|
|
|
75
77
|
first = false;
|
|
76
78
|
appendFileSync(file, `${lead}${JSON.stringify({ v: EVENTS_SCHEMA, at: new Date().toISOString(), ...e })}\n`);
|
|
77
79
|
} catch {
|
|
80
|
+
try { onFailure?.(e); } catch { /* telemetry failure reporting is optional too */ }
|
|
78
81
|
// the state dir is gone; the watchdog is stopping the hub
|
|
79
82
|
}
|
|
80
83
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
2
3
|
|
|
3
4
|
export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
|
|
4
5
|
export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
|
|
@@ -42,6 +43,7 @@ export class ExecutionBudget {
|
|
|
42
43
|
private readonly db: Database;
|
|
43
44
|
constructor(path: string) {
|
|
44
45
|
this.db = new Database(path, { create: true });
|
|
46
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
45
47
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
46
48
|
this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
|
|
47
49
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
|
package/src/hub/facts.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync,
|
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { sanitize, type PeerId } from "./envelope.ts";
|
|
5
5
|
import { isDenied } from "../local/deny.ts";
|
|
6
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
6
7
|
import { realPath } from "./project.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -275,7 +276,7 @@ export class Facts {
|
|
|
275
276
|
// One path per line: a newline in a name would shift the answers, and git drops a final carriage return (another file).
|
|
276
277
|
files = files.filter((f) => !/[\r\n]/.test(f));
|
|
277
278
|
if (!files.length) return out;
|
|
278
|
-
const r = Bun.spawnSync(["
|
|
279
|
+
const r = Bun.spawnSync(hubGitArgv(["cat-file", "--batch-check=%(objectname) %(objecttype)"]), { cwd: this.root, stdin: new TextEncoder().encode(files.map((f) => `HEAD:./${f}`).join("\n") + "\n"), stdout: "pipe", stderr: "pipe", env: hubGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }) });
|
|
279
280
|
if (r.exitCode !== 0) return out;
|
|
280
281
|
r.stdout.toString().split("\n").forEach((line, i) => {
|
|
281
282
|
const [oid, type] = line.split(" ");
|
|
@@ -414,7 +415,7 @@ export class Facts {
|
|
|
414
415
|
if (!files) {
|
|
415
416
|
// Plumbing only, with optional locks off: an agent's own git commands must never meet a lock the hub holds.
|
|
416
417
|
const git = (...args: string[]) => {
|
|
417
|
-
const r = Bun.spawnSync([
|
|
418
|
+
const r = Bun.spawnSync(hubGitArgv([...args, "--", p]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: hubGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }) });
|
|
418
419
|
return r.exitCode === 0 ? r.stdout.toString().split("\0").filter(Boolean) : undefined;
|
|
419
420
|
};
|
|
420
421
|
// Against HEAD, so a staged change counts (a staged move by both names); a repository without a commit yet has
|
|
@@ -693,7 +694,7 @@ export class Facts {
|
|
|
693
694
|
tree(paths: string[], windows: { peer: PeerId; since: number; until?: number }[] = []): string {
|
|
694
695
|
this.boundary();
|
|
695
696
|
const h = createHash("sha1");
|
|
696
|
-
const head = Bun.spawnSync(["
|
|
697
|
+
const head = Bun.spawnSync(hubGitArgv(["rev-parse", "-q", "--verify", "HEAD"]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
|
|
697
698
|
h.update(head.exitCode === 0 ? head.stdout.toString().trim() : "no-head").update("\0");
|
|
698
699
|
// The files the members wrote while at work in the cohort count too: a symbol-only overlap names no path, and a
|
|
699
700
|
// tool's write outside the named paths still moves the work the integration checked. What they only read never
|
|
@@ -780,7 +781,7 @@ export class Facts {
|
|
|
780
781
|
try {
|
|
781
782
|
writeFileSync(a, before, { mode: 0o600 });
|
|
782
783
|
writeFileSync(b, after, { mode: 0o600 });
|
|
783
|
-
const r = Bun.spawnSync(["
|
|
784
|
+
const r = Bun.spawnSync(hubGitArgv(["-c", "core.quotepath=off", "diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
|
|
784
785
|
const lines = r.stdout.toString().split("\n");
|
|
785
786
|
const start = lines.findIndex((l) => l.startsWith("@@"));
|
|
786
787
|
if (start === -1) return [];
|
package/src/hub/git.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
|
|
5
|
+
* must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
|
|
6
|
+
* which refuses a sandboxed command the writes that would plant one). Every call gets:
|
|
7
|
+
* - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
|
|
8
|
+
* - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
|
|
9
|
+
* - diff.external= (empty): no external diff driver.
|
|
10
|
+
* - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
|
|
11
|
+
* - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
|
|
12
|
+
* no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
|
|
13
|
+
* git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
|
|
14
|
+
* only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
|
|
15
|
+
* hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
|
|
16
|
+
* diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
|
|
17
|
+
* and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
|
|
18
|
+
* command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
|
|
19
|
+
*/
|
|
20
|
+
const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
|
|
21
|
+
|
|
22
|
+
/** `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. */
|
|
23
|
+
export const hubGitArgv = (args: string[]): string[] => ["git", "--no-pager", ...SAFE_CONFIG.flatMap((c) => ["-c", c]), ...args];
|
|
24
|
+
|
|
25
|
+
/** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
|
|
26
|
+
export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
|
|
27
|
+
|
|
28
|
+
/** spawnSync form of the same call: the env option is merged with hubGitEnv, everything else passes through. */
|
|
29
|
+
export function hubGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|
|
30
|
+
const argv = hubGitArgv(args);
|
|
31
|
+
return spawnSync(argv[0]!, argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
|
|
32
|
+
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -53,7 +53,7 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
|
53
53
|
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
54
54
|
tool("hub_task_assign", "Move a task to another peer, as the conductor, or redirect a task you proposed while it is proposed and nobody accepted it. A task that still waits gets the peer as its reserved owner. When you have an explicit capabilities list, the conductor needs assign capability for any assign, the proposer only to hand the task to another peer.", { id, peer: str }, ["id", "peer"]),
|
|
55
55
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
56
|
-
tool("hub_peer_start", "Start local, kimi or headless
|
|
56
|
+
tool("hub_peer_start", "Start local, kimi, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] } }, ["peer"]),
|
|
57
57
|
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
|
|
58
58
|
tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
|
|
59
59
|
];
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -5,19 +6,21 @@ import { join } from "node:path";
|
|
|
5
6
|
import { createServer, type Server } from "node:net";
|
|
6
7
|
import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
7
8
|
import { startDaemon } from "./daemon.ts";
|
|
9
|
+
import { terminalOpener } from "./terminal-open.ts";
|
|
8
10
|
import { Registry, type Project } from "./registry.ts";
|
|
9
11
|
import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
|
|
10
12
|
import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
|
|
11
13
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
12
14
|
|
|
13
15
|
export type ProjectInspection = {
|
|
16
|
+
failure?: { code: "database_busy" };
|
|
14
17
|
state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
|
|
15
18
|
status?: any;
|
|
16
19
|
error?: string;
|
|
17
20
|
};
|
|
18
21
|
|
|
19
22
|
/** A port or PID alone is never evidence that the selected project's hub is alive. */
|
|
20
|
-
export async function inspectProject(project: Project): Promise<ProjectInspection> {
|
|
23
|
+
export async function inspectProject(project: Project, timeoutMs = 3000): Promise<ProjectInspection> {
|
|
21
24
|
if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
|
|
22
25
|
const control = readControl(project.stateDir);
|
|
23
26
|
if (control) {
|
|
@@ -32,8 +35,9 @@ export async function inspectProject(project: Project): Promise<ProjectInspectio
|
|
|
32
35
|
try {
|
|
33
36
|
// Older manifests omit identity. Authenticate first, then require the full current contract.
|
|
34
37
|
client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root,
|
|
35
|
-
...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) });
|
|
36
|
-
const response = await client.request({ t: "status" },
|
|
38
|
+
...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) }, timeoutMs);
|
|
39
|
+
const response = await client.request({ t: "status" }, timeoutMs);
|
|
40
|
+
if (isDatabaseBusyReply(response)) return { state: "unavailable", error: new RecoveryDatabaseBusy().message, failure: { code: "database_busy" } };
|
|
37
41
|
if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
|
|
38
42
|
return { state: "unavailable", error: "authenticated hub identity does not match registration" };
|
|
39
43
|
}
|
|
@@ -112,7 +116,9 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
112
116
|
try {
|
|
113
117
|
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
114
118
|
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
115
|
-
switchyardPort: base + SWITCHYARD, unattended, onShutdownStart
|
|
119
|
+
switchyardPort: base + SWITCHYARD, unattended, onShutdownStart,
|
|
120
|
+
// #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
|
|
121
|
+
terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
|
|
116
122
|
break;
|
|
117
123
|
} catch (error) {
|
|
118
124
|
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
package/src/hub/manager.ts
CHANGED
|
@@ -9,15 +9,19 @@ 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 { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
|
|
12
13
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
13
14
|
|
|
15
|
+
const MANAGER_OWNER_DB_BUSY_TIMEOUT_MS = 5000;
|
|
14
16
|
export type ProjectRecord = Project;
|
|
15
17
|
export type Lifecycle = {
|
|
16
18
|
inspectProject(project: Project): Promise<ProjectInspection>;
|
|
17
19
|
startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
|
|
18
20
|
stopProject(project: Project, expectedInstance?: string): Promise<void>;
|
|
19
21
|
};
|
|
20
|
-
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number
|
|
22
|
+
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
|
|
23
|
+
/** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
|
|
24
|
+
forwardTimeoutMs?: (action: string, nominalMs: number) => number };
|
|
21
25
|
type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
22
26
|
type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
|
|
23
27
|
const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
|
|
@@ -45,7 +49,7 @@ async function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
|
45
49
|
function claimOwner(path: string, instanceId: string, signature: string | undefined): Database {
|
|
46
50
|
const db = new Database(path, { create: true });
|
|
47
51
|
try {
|
|
48
|
-
db.run(
|
|
52
|
+
db.run(`PRAGMA busy_timeout = ${MANAGER_OWNER_DB_BUSY_TIMEOUT_MS}`);
|
|
49
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, signature TEXT)");
|
|
50
54
|
if (!(db.query("PRAGMA table_info(owner)").all() as { name: string }[]).some((c) => c.name === "signature")) {
|
|
51
55
|
try { db.run("ALTER TABLE owner ADD COLUMN signature TEXT"); }
|
|
@@ -148,11 +152,20 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
148
152
|
await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
|
|
149
153
|
return { ok: true };
|
|
150
154
|
}
|
|
151
|
-
|
|
155
|
+
// The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
|
|
156
|
+
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "setting", "setting_undo", "setting_preview", "start_peer"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
|
|
152
157
|
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
|
|
153
|
-
|
|
158
|
+
const nominalMs = input.action === "start_peer" ? MAX_COMMAND_MS + 5_000 : 10_000;
|
|
159
|
+
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
160
|
+
const result = await client.request(input.action === "snapshot"
|
|
154
161
|
? { t: "ui_snapshot", after: input.after ?? 0 }
|
|
155
|
-
: { t: "ui_action", action: input, instanceId: input.instanceId },
|
|
162
|
+
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
163
|
+
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
164
|
+
if (input.action === "start_peer" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|
|
165
|
+
const text = "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
|
|
166
|
+
return { ok: false, unconfirmed: true, text, error: text };
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
156
169
|
} catch (error) { return { ok: false, error: safeError(error) }; }
|
|
157
170
|
finally { client?.close(); }
|
|
158
171
|
};
|
package/src/hub/peers.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface DeliveryReceipt {
|
|
|
4
4
|
id: string;
|
|
5
5
|
state: "accepted" | "completed" | "failed_safe" | "needs_review";
|
|
6
6
|
reason?: string;
|
|
7
|
+
/** A provider refused before acceptance for quota; requeue without spending poison retries while paused. */
|
|
8
|
+
quota?: true;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export interface PeerAdapter {
|
|
@@ -18,13 +20,13 @@ export interface PeerAdapter {
|
|
|
18
20
|
/** Optional: feed envelopes into the turn that is running now. Only called while `state === "busy"`. */
|
|
19
21
|
steer?(envs: Envelope[], deliveryId?: string): Promise<void>;
|
|
20
22
|
start(): Promise<void>;
|
|
21
|
-
stop(): Promise<void>;
|
|
23
|
+
stop(reason?: string): Promise<void>;
|
|
22
24
|
/** Set by the bus. The peer said something worth sharing. */
|
|
23
25
|
onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
|
|
24
26
|
/** Set by the bus. */
|
|
25
27
|
onState?: (state: PeerState) => void;
|
|
26
28
|
/** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
|
|
27
|
-
onFailed?: (envs: Envelope[]) => void;
|
|
29
|
+
onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
|
|
28
30
|
/** Correlated durable-delivery lifecycle, emitted only when the bus supplied an id. */
|
|
29
31
|
onDelivery?: (receipt: DeliveryReceipt) => void;
|
|
30
32
|
/** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
|
|
@@ -38,7 +40,7 @@ export abstract class BasePeer implements PeerAdapter {
|
|
|
38
40
|
/** Returns a reason (a string) when the hub refused the message (issue #38). */
|
|
39
41
|
onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
|
|
40
42
|
onState?: (state: PeerState) => void;
|
|
41
|
-
onFailed?: (envs: Envelope[]) => void;
|
|
43
|
+
onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
|
|
42
44
|
onDelivery?: (receipt: DeliveryReceipt) => void;
|
|
43
45
|
private _state: PeerState = "offline";
|
|
44
46
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -77,12 +79,12 @@ export abstract class BasePeer implements PeerAdapter {
|
|
|
77
79
|
this.onDelivery?.(receipt);
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
protected clearWatchdog(): void {
|
|
81
83
|
if (this.timer) clearTimeout(this.timer);
|
|
82
84
|
this.timer = undefined;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
abstract deliver(envs: Envelope[], deliveryId?: string): Promise<void>;
|
|
86
88
|
abstract start(): Promise<void>;
|
|
87
|
-
abstract stop(): Promise<void>;
|
|
89
|
+
abstract stop(reason?: string): Promise<void>;
|
|
88
90
|
}
|
|
@@ -17,6 +17,20 @@ export function permissionDefaults(value: unknown): Record<string, PermissionMod
|
|
|
17
17
|
}
|
|
18
18
|
return result;
|
|
19
19
|
}
|
|
20
|
+
/** Approval frequency does not create a common sandbox across native peers. */
|
|
21
|
+
export function permissionBoundary(peer: string): string {
|
|
22
|
+
if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
23
|
+
if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
24
|
+
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
25
|
+
}
|
|
26
|
+
/** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
|
|
27
|
+
export function permissionGrant(peer: string, mode: PermissionMode): string {
|
|
28
|
+
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";
|
|
29
|
+
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";
|
|
30
|
+
if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
|
|
31
|
+
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";
|
|
32
|
+
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
|
|
33
|
+
}
|
|
20
34
|
export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
|
|
21
35
|
/** Native agent policy files are never included in scoped automatic file grants. */
|
|
22
36
|
export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".pi", ".mcp.json"]);
|
package/src/hub/project.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { hubGitSync } from "./git.ts";
|
|
5
6
|
|
|
6
7
|
const canonical = (path: string): string => {
|
|
7
8
|
const absolute = resolve(path);
|
|
@@ -39,7 +40,7 @@ export function realPath(path: string): string {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
function gitRoot(dir: string): string | undefined {
|
|
42
|
-
const result =
|
|
43
|
+
const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
43
44
|
if (result.status !== 0) return undefined;
|
|
44
45
|
const root = result.stdout.trim();
|
|
45
46
|
return root ? canonical(root) : undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
|
|
3
|
+
/** Additive recovery outcome: older clients still receive ok:false and a useful error. */
|
|
4
|
+
export class RecoveryDatabaseBusy extends Error {
|
|
5
|
+
readonly code = "database_busy";
|
|
6
|
+
readonly retryable = true;
|
|
7
|
+
constructor() {
|
|
8
|
+
super("database_busy: a database is busy; wait for the other connection to settle before retrying the request");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isDatabaseBusy(error: unknown): boolean {
|
|
13
|
+
if (error instanceof RecoveryDatabaseBusy) return true;
|
|
14
|
+
if (!error || typeof error !== "object") return false;
|
|
15
|
+
const value = error as { code?: unknown; errno?: unknown };
|
|
16
|
+
if (typeof value.code === "string") return /^(SQLITE_BUSY|SQLITE_LOCKED)(_|$)/.test(value.code);
|
|
17
|
+
return error instanceof Error && error.name === "SQLiteError"
|
|
18
|
+
&& typeof value.errno === "number" && value.errno > 0 && [5, 6].includes(value.errno & 255);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Only exact legacy SQLite refusals are inferred from an authenticated hub reply. */
|
|
22
|
+
export function isDatabaseBusyReply(reply: any): boolean {
|
|
23
|
+
return reply?.ok === false && (reply.code === "database_busy"
|
|
24
|
+
|| reply.error === "database is locked" || reply.error === "database table is locked");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Frames only: retain the throw site without carrying a native error's message. */
|
|
28
|
+
export function databaseBusySite(error: unknown): string[] {
|
|
29
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
30
|
+
return (stack ?? "").split("\n").filter(line => /^\s*at /.test(line)).slice(0, 8).map(line => sanitize(line.trim()).slice(0, 400));
|
|
31
|
+
}
|
|
@@ -5,6 +5,7 @@ import { Database } from "bun:sqlite";
|
|
|
5
5
|
import { hubHome } from "./project.ts";
|
|
6
6
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
7
7
|
|
|
8
|
+
const RUNNER_DB_BUSY_TIMEOUT_MS = 3000;
|
|
8
9
|
export const operationIdPattern = /^[a-f0-9-]{36}$/;
|
|
9
10
|
export function operationPath(id: string, home = hubHome()): string {
|
|
10
11
|
if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
|
|
@@ -74,6 +75,12 @@ export function recoveryCommand(op: { id: string; sourceRoot?: string }, action:
|
|
|
74
75
|
return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/** #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");
|
|
81
|
+
return [entry, "recovery", action, op.id, ...flags];
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
/**
|
|
78
85
|
* Whether a coordinator has the #215 recovery commands (dispose, re-preparation, next actions).
|
|
79
86
|
* ponytail: a text sniff of its upgrade.ts; a rename or re-export reads as an older coordinator (the running release is
|
|
@@ -105,7 +112,7 @@ export function recoveryRunner(id: string, home = hubHome()): number | "unknown"
|
|
|
105
112
|
let db: Database | undefined;
|
|
106
113
|
try {
|
|
107
114
|
db = new Database(path, { readonly: true });
|
|
108
|
-
db.run(
|
|
115
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`); // a claim in progress must not read as no runner
|
|
109
116
|
// A crash between creating the file and its table leaves no runner; claimRunner creates the table and claims.
|
|
110
117
|
if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runner'").get()) return undefined;
|
|
111
118
|
const row = db.query(`SELECT pid${signed(db) ? ", signature" : ""} FROM runner WHERE slot = 1`).get() as { pid: number; signature?: string | null } | null;
|
|
@@ -116,14 +123,46 @@ export function recoveryRunner(id: string, home = hubHome()): number | "unknown"
|
|
|
116
123
|
} catch { return "unknown"; } finally { db?.close(); }
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
/**
|
|
127
|
+
* #272: the pid of the runner that holds `id`, only when its claim carries a process signature that still matches.
|
|
128
|
+
* `recoveryRunner` answers a claim without one (a coordinator of 0.12.20 or older, or a `ps` that failed at claim
|
|
129
|
+
* time) from its bare pid, which is enough to wait for it and never enough to signal it.
|
|
130
|
+
*/
|
|
131
|
+
export function signedRunner(id: string, home = hubHome()): number | undefined {
|
|
132
|
+
const path = `${operationPath(id, home)}.runner.db`;
|
|
133
|
+
if (!existsSync(path)) return undefined;
|
|
134
|
+
let db: Database | undefined;
|
|
135
|
+
try {
|
|
136
|
+
db = new Database(path, { readonly: true });
|
|
137
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
|
|
138
|
+
if (!signed(db)) return undefined;
|
|
139
|
+
const row = db.query("SELECT pid, signature FROM runner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
|
|
140
|
+
if (!row?.signature || !Number.isSafeInteger(row.pid) || row.pid < 1) return undefined;
|
|
141
|
+
return processSignature(row.pid) === row.signature ? row.pid : undefined;
|
|
142
|
+
} catch { return undefined; } finally { db?.close(); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** #272: stop that verified runner and wait until no runner holds the operation. False when one still does, or cannot be verified. */
|
|
146
|
+
export async function stopSignedRunner(id: string, home = hubHome(), waitMs = 5000): Promise<boolean> {
|
|
147
|
+
if (recoveryRunner(id, home) === undefined) return true;
|
|
148
|
+
const pid = signedRunner(id, home);
|
|
149
|
+
if (pid === undefined) return false;
|
|
150
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* gone meanwhile */ }
|
|
151
|
+
for (const deadline = Date.now() + waitMs; Date.now() < deadline;) {
|
|
152
|
+
if (recoveryRunner(id, home) === undefined) return true;
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
/** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
|
|
120
159
|
export function claimRunner(id: string, home = hubHome()): () => void {
|
|
121
160
|
const path = `${operationPath(id, home)}.runner.db`;
|
|
122
|
-
const db = new Database(path, { create: true });
|
|
123
|
-
chmodSync(path, 0o600);
|
|
124
161
|
const nonce = randomUUID();
|
|
162
|
+
const db = new Database(path, { create: true });
|
|
125
163
|
try {
|
|
126
|
-
db.run(
|
|
164
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
|
|
165
|
+
chmodSync(path, 0o600);
|
|
127
166
|
db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL, signature TEXT)");
|
|
128
167
|
if (!signed(db)) {
|
|
129
168
|
try { db.run("ALTER TABLE runner ADD COLUMN signature TEXT"); }
|
package/src/hub/registry.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { processLiveness } from "../pi/process-signature.ts";
|
|
|
7
7
|
|
|
8
8
|
const BASE_PORT = 4600;
|
|
9
9
|
const STRIDE = 10;
|
|
10
|
+
export const REGISTRY_DB_BUSY_TIMEOUT_MS = 5000;
|
|
10
11
|
const MAX_PORT = 65535 - 3;
|
|
11
12
|
const LEGACY_DONE = "legacy_ports_imported";
|
|
12
13
|
type Tx = { immediate(): void };
|
|
@@ -50,7 +51,7 @@ export class Registry {
|
|
|
50
51
|
this.path = path;
|
|
51
52
|
mkdirSync(dirname(path), { recursive: true });
|
|
52
53
|
this.db = new Database(path, { create: true });
|
|
53
|
-
this.db.run(
|
|
54
|
+
this.db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
|
|
54
55
|
try {
|
|
55
56
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
56
57
|
} catch (error) {
|