@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
package/src/cli/help.ts
CHANGED
|
@@ -24,11 +24,11 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
24
24
|
["ahub doctor --orphans [--kill]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
|
|
25
25
|
["ahub kill", "stop this project's hub"],
|
|
26
26
|
["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
|
|
27
|
-
["ahub restart [--dry-run] [--yes]", "recover this project's runtime"],
|
|
28
|
-
["ahub upgrade --to <version> [--dry-run] [--yes]", "review and upgrade running projects"],
|
|
29
|
-
["ahub recovery status|resume|abort <operation-id>", "inspect, resume or cancel a preflight; status names the next actions"],
|
|
30
|
-
["ahub recovery dispose <operation-id> --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
31
|
-
["ahub recovery dispose <operation-id> --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
27
|
+
["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
|
|
28
|
+
["ahub upgrade [--to <version>] [--dry-run] [--yes]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
|
|
29
|
+
["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
|
|
30
|
+
["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
31
|
+
["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
32
32
|
["ahub daemon [--unattended]", "internal: the detached hub process that ahub up or the dashboard manager starts"],
|
|
33
33
|
["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
|
|
34
34
|
]],
|
|
@@ -38,11 +38,13 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
38
38
|
["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
|
|
39
39
|
["ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi"],
|
|
40
40
|
["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
|
|
41
|
+
["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
|
|
41
42
|
["ahub models setup|status|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
|
|
42
43
|
]],
|
|
43
44
|
["Messages and approvals", [
|
|
44
45
|
["ahub say [@peer ...] <text>", "send as the console user (no @peer = broadcast); delivered at once, start the text with [STATUS] to let it batch or [FYI] for the record only"],
|
|
45
46
|
["ahub tail", "live stream of messages, states and permission requests"],
|
|
47
|
+
["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
|
|
46
48
|
["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
|
|
47
49
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
48
50
|
["ahub queue list [--peer <id>] [--json]", "inspect durable deliveries"],
|
|
@@ -54,6 +56,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
54
56
|
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
55
57
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
56
58
|
["ahub task assign <id> <peer>", "give a task to a peer yourself; a task that still waits is reserved for that peer instead"],
|
|
59
|
+
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
57
60
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
58
61
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
59
62
|
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
@@ -71,6 +74,14 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
71
74
|
["ahub undo <turn> [--yes] [--context]", "put back the files a turn changed; refuses files changed since. Without --yes it only lists them; --context also drops a Codex turn from its conversation"],
|
|
72
75
|
["ahub report [--since 7d|<iso>] [--by task] [--json]", "turns, tokens, messages, overlaps and task events per period"],
|
|
73
76
|
["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
|
|
77
|
+
["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
|
|
78
|
+
["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
|
|
79
|
+
["ahub research backfill", "build this project's research records once from events.jsonl"],
|
|
80
|
+
["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree)"],
|
|
81
|
+
["ahub bench list|status", "benchmark runs on this machine / the one running now"],
|
|
82
|
+
["ahub bench report <run> [--json]", "pass rate, first pass, rework, tokens and wall time per task and overall"],
|
|
83
|
+
["ahub bench compare <run|arm>... [--suite <name>] [--mixed] [--json]", "compare arms of one suite with 95% bootstrap intervals; inconclusive below 5 counted attempts per arm"],
|
|
84
|
+
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
74
85
|
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
75
86
|
]],
|
|
76
87
|
["Hooks", [
|
package/src/cli/identity.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
|
26
26
|
|
|
27
27
|
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
28
28
|
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
29
|
+
if (command === "permission" || command === "stop") return "console";
|
|
29
30
|
if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
|
|
30
31
|
if (command === "task") {
|
|
31
32
|
if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
|
|
@@ -33,6 +34,11 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
|
|
|
33
34
|
return "console";
|
|
34
35
|
}
|
|
35
36
|
if (command === "route") return args[0] === "explain" ? "allowed" : "console";
|
|
37
|
+
// #247: reading the research measures and records is harmless (ids and counts only); writing them is a person's.
|
|
38
|
+
if (command === "research") return args[0] === "backfill" ? "console" : "allowed";
|
|
39
|
+
// #251: a run resets the bench project's tree and proposes work; reading results is harmless.
|
|
40
|
+
// The subcommand is the first argument that is not --json, which the command accepts anywhere.
|
|
41
|
+
if (command === "bench") return args.find((arg) => arg !== "--json") === "run" ? "console" : "allowed";
|
|
36
42
|
if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
|
|
37
43
|
if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
|
|
38
44
|
if (["help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
|
|
@@ -41,10 +47,10 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
|
|
|
41
47
|
|
|
42
48
|
/** No argument text is included: notices and refusal logs must not copy task text or credentials. */
|
|
43
49
|
export function cliCommandLabel(command: string, args: readonly string[]): string {
|
|
44
|
-
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "status", "logs", "export", "report", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
45
|
-
if (["task", "queue", "budget", "route", "recovery", "models", "projects"].includes(command)) {
|
|
46
|
-
const sub = args[0];
|
|
47
|
-
if (sub && ["propose", "show", "assign", "escalate", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove"].includes(sub)) return `${command} ${sub}`;
|
|
50
|
+
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "stop", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
51
|
+
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "bench"].includes(command)) {
|
|
52
|
+
const sub = command === "bench" ? args.find((arg) => arg !== "--json") : args[0];
|
|
53
|
+
if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "run", "report", "compare"].includes(sub)) return `${command} ${sub}`;
|
|
48
54
|
}
|
|
49
55
|
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
50
56
|
}
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
5
|
+
import { resolve, join, basename, dirname, isAbsolute } from "node:path";
|
|
6
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
7
|
+
import { realPath } from "../hub/project.ts";
|
|
8
|
+
import { peerChildEnv, processTable } from "../hub/child-process.ts";
|
|
5
9
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
6
10
|
|
|
7
11
|
/** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
|
|
@@ -40,6 +44,210 @@ export interface Launch {
|
|
|
40
44
|
cmd: string;
|
|
41
45
|
args: string[];
|
|
42
46
|
warning?: string;
|
|
47
|
+
permissionHook?: boolean;
|
|
48
|
+
hookPurpose?: FactsHook["purpose"];
|
|
49
|
+
settingsFile?: string;
|
|
50
|
+
unattended?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Retire only our prior private Claude settings file, after the replacement launch record is committed. */
|
|
54
|
+
export function cleanupClaudeSettings(stateDir: string, previous: unknown): void {
|
|
55
|
+
if (typeof previous !== "string" || !isAbsolute(previous) || !/^claude-settings-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/.test(basename(previous))) return;
|
|
56
|
+
try {
|
|
57
|
+
const root = realPath(stateDir);
|
|
58
|
+
if (realPath(dirname(previous)) !== root) return;
|
|
59
|
+
const stat = lstatSync(previous);
|
|
60
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
61
|
+
if (realPath(previous) !== join(root, basename(previous))) return;
|
|
62
|
+
unlinkSync(previous);
|
|
63
|
+
} catch { /* Missing or unverifiable files remain untouched. */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Crash fallback only: an absent/unreadable owner identity never certifies that its native launch ended. */
|
|
67
|
+
export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown, identity = processSignature): void {
|
|
68
|
+
if (!previous || typeof previous !== "object" || Array.isArray(previous)) return;
|
|
69
|
+
const original = previous as Record<string, unknown>;
|
|
70
|
+
const record = readClaudeNative(stateDir, original as ClaudeLaunchRecord) ?? original;
|
|
71
|
+
if (typeof record.launcherSignature !== "string" || !record.launcherSignature) return;
|
|
72
|
+
if (processLiveness(record.launcherPid, record.launcherSignature, identity) !== "gone") return;
|
|
73
|
+
if (typeof record.nativePid !== "number" || !Number.isSafeInteger(record.nativePid) || record.nativePid <= 0 || record.nativePid === record.launcherPid || typeof record.nativeSignature !== "string" || !record.nativeSignature) return;
|
|
74
|
+
if (record.nativeIdentity === "direct-child") {
|
|
75
|
+
// A direct child may exec in place. A changed signature while its PID still runs cannot certify native exit.
|
|
76
|
+
// The shared table excludes exited zombies; an unavailable table certifies nothing.
|
|
77
|
+
const table = processTable();
|
|
78
|
+
if (!table || table.some(row => row.pid === record.nativePid)) return;
|
|
79
|
+
} else if (processLiveness(record.nativePid, record.nativeSignature, identity) !== "gone") return;
|
|
80
|
+
cleanupClaudeSettings(stateDir, record.settingsFile);
|
|
81
|
+
removeClaudeNative(stateDir, original as ClaudeLaunchRecord);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ClaudeLaunchRecord extends Record<string, unknown> {
|
|
85
|
+
instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
|
|
89
|
+
|
|
90
|
+
/** Native attestation alone is serialized; managed hook metadata never waits on this lock. */
|
|
91
|
+
async function withClaudeLaunchRecord(stateDir: string, action: () => void): Promise<ClaudeLaunchPublication> {
|
|
92
|
+
const lock = join(stateDir, "claude-launch.lock"), temp = `${lock}.${randomUUID()}.tmp`;
|
|
93
|
+
const skipped = (reason = `${lock} is busy or unverified`): ClaudeLaunchPublication => ({ recorded: false, warning: `Native identity and crash cleanup disabled for this launch; managed hook record stays current. ${reason}; inspect ${lock} and verify its owner has ended before removing it.` });
|
|
94
|
+
const signature = processSignature(process.pid);
|
|
95
|
+
if (!signature) return skipped("launcher identity could not be verified");
|
|
96
|
+
let locked = false, tempCreated = false, own: { dev: number; ino: number } | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const fd = openSync(temp, "wx", 0o600); tempCreated = true;
|
|
99
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, signature })); } finally { closeSync(fd); }
|
|
100
|
+
const stat = lstatSync(temp); own = { dev: stat.dev, ino: stat.ino };
|
|
101
|
+
const deadline = performance.now() + 250;
|
|
102
|
+
for (;;) {
|
|
103
|
+
try { linkSync(temp, lock); break; }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return skipped();
|
|
106
|
+
let before;
|
|
107
|
+
try { before = lstatSync(lock); } catch { if (performance.now() < deadline) continue; return skipped(); }
|
|
108
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || !process.getuid || before.uid !== process.getuid()) return skipped();
|
|
109
|
+
let owner: { pid?: unknown; signature?: unknown };
|
|
110
|
+
try { owner = JSON.parse(readFileSync(lock, "utf8")); } catch { return skipped(); }
|
|
111
|
+
if (!owner || typeof owner.signature !== "string" || !owner.signature) return skipped();
|
|
112
|
+
const alive = processLiveness(owner.pid, owner.signature);
|
|
113
|
+
if (alive === "unknown") return skipped();
|
|
114
|
+
if (alive === "live") {
|
|
115
|
+
if (performance.now() >= deadline) return skipped();
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 25)); continue;
|
|
117
|
+
}
|
|
118
|
+
const current = lstatSync(lock);
|
|
119
|
+
if (current.dev !== before.dev || current.ino !== before.ino) return skipped();
|
|
120
|
+
unlinkSync(lock);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
locked = true;
|
|
124
|
+
unlinkSync(temp);
|
|
125
|
+
try { action(); } catch { return skipped("native attestation publication failed"); }
|
|
126
|
+
return { recorded: true };
|
|
127
|
+
} catch { return skipped("native attestation lock could not be read or published"); }
|
|
128
|
+
finally {
|
|
129
|
+
if (locked && own) {
|
|
130
|
+
try { const current = lstatSync(lock); if (current.dev === own.dev && current.ino === own.ino) unlinkSync(lock); }
|
|
131
|
+
catch { /* only our verified lock may be removed */ }
|
|
132
|
+
}
|
|
133
|
+
if (tempCreated) { try { unlinkSync(temp); } catch { /* our populated temp may already be linked and consumed */ } }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
|
|
138
|
+
const file = join(stateDir, "claude-launch.json"), temp = `${file}.${randomUUID()}.tmp`;
|
|
139
|
+
try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
|
|
140
|
+
finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function recordClaudeLaunch(stateDir: string, record: ClaudeLaunchRecord): ClaudeLaunchPublication {
|
|
144
|
+
writeClaudeLaunchRecord(stateDir, record);
|
|
145
|
+
return { recorded: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sameClaudeLaunch(left: ClaudeLaunchRecord, right: ClaudeLaunchRecord): boolean {
|
|
149
|
+
return left.instanceId === right.instanceId && left.launchId === right.launchId && left.settingsFile === right.settingsFile
|
|
150
|
+
&& left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature;
|
|
151
|
+
}
|
|
152
|
+
function nativeAttestationPath(stateDir: string, launch: ClaudeLaunchRecord): string {
|
|
153
|
+
const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature])).digest("hex");
|
|
154
|
+
return join(stateDir, `claude-native-${hash}.json`);
|
|
155
|
+
}
|
|
156
|
+
function privateClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): { file: string; stat: ReturnType<typeof lstatSync>; record: ClaudeLaunchRecord } | undefined {
|
|
157
|
+
const file = nativeAttestationPath(stateDir, launch);
|
|
158
|
+
try {
|
|
159
|
+
const stat = lstatSync(file);
|
|
160
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
161
|
+
const record = JSON.parse(readFileSync(file, "utf8")) as ClaudeLaunchRecord;
|
|
162
|
+
if (!record || !sameClaudeLaunch(record, launch) || typeof record.nativePid !== "number" || !Number.isSafeInteger(record.nativePid) || record.nativePid <= 0 || typeof record.nativeSignature !== "string" || !record.nativeSignature || record.nativeIdentity !== "direct-child") return;
|
|
163
|
+
return { file, stat, record };
|
|
164
|
+
} catch { return; }
|
|
165
|
+
}
|
|
166
|
+
export function readClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): ClaudeLaunchRecord | undefined {
|
|
167
|
+
return privateClaudeNative(stateDir, launch)?.record;
|
|
168
|
+
}
|
|
169
|
+
function removeClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): void {
|
|
170
|
+
const saved = privateClaudeNative(stateDir, launch); if (!saved?.stat) return;
|
|
171
|
+
try { const now = lstatSync(saved.file); if (now.dev === saved.stat.dev && now.ino === saved.stat.ino) unlinkSync(saved.file); }
|
|
172
|
+
catch { /* absent or replaced native proof remains untouched */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Never attach an old child's identity to a replacement launch, even when its spawn callback arrives later. */
|
|
176
|
+
export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchRecord, pid: number, signature: string, report?: (warning: string) => void): Promise<boolean> {
|
|
177
|
+
let recorded = false;
|
|
178
|
+
const publication = await withClaudeLaunchRecord(stateDir, () => {
|
|
179
|
+
let current: ClaudeLaunchRecord;
|
|
180
|
+
try {
|
|
181
|
+
const file = join(stateDir, "claude-launch.json"), stat = lstatSync(file);
|
|
182
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
183
|
+
current = JSON.parse(readFileSync(file, "utf8"));
|
|
184
|
+
} catch { return; }
|
|
185
|
+
if (!sameClaudeLaunch(current, launch)) return;
|
|
186
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === launch.launcherPid || !signature) return;
|
|
187
|
+
const file = nativeAttestationPath(stateDir, launch), temp = `${file}.${randomUUID()}.tmp`;
|
|
188
|
+
try {
|
|
189
|
+
lstatSync(file);
|
|
190
|
+
const saved = privateClaudeNative(stateDir, launch);
|
|
191
|
+
recorded = !!saved && saved.record.nativePid === pid && saved.record.nativeSignature === signature;
|
|
192
|
+
return; // a launch's first native proof is immutable; an unknown file is never overwritten
|
|
193
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; }
|
|
194
|
+
try {
|
|
195
|
+
writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
|
|
196
|
+
renameSync(temp, file);
|
|
197
|
+
} finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
|
|
198
|
+
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
|
|
199
|
+
});
|
|
200
|
+
if (publication.warning) report?.(publication.warning);
|
|
201
|
+
return publication.recorded && recorded;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Scripts and interpreters can leave a different native owner behind: their crash cleanup remains disabled. */
|
|
205
|
+
function directNativeExecutable(command: string, cwd: string, env: NodeJS.ProcessEnv): boolean {
|
|
206
|
+
const interpreter = /^(?:node|bun|deno|env|busybox|python(?:[0-9.]+)?|ruby|perl|bash|sh|zsh|fish|dash|ksh|csh|tcsh|ash)(?:\.exe)?$/;
|
|
207
|
+
if (interpreter.test(basename(command))) return false;
|
|
208
|
+
const paths = command.includes("/") ? [resolve(cwd, command)] : (env.PATH ?? "").split(":").filter(Boolean).map(path => resolve(cwd, path, command));
|
|
209
|
+
for (const path of paths) {
|
|
210
|
+
let fd: number | undefined;
|
|
211
|
+
try {
|
|
212
|
+
const target = realPath(path);
|
|
213
|
+
accessSync(target, constants.X_OK);
|
|
214
|
+
if (interpreter.test(basename(target))) return false;
|
|
215
|
+
fd = openSync(target, "r");
|
|
216
|
+
const bytes = Buffer.alloc(4); if (readSync(fd, bytes, 0, 4, 0) !== 4) return false;
|
|
217
|
+
return ["7f454c46", "feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca"].includes(bytes.toString("hex"));
|
|
218
|
+
} catch { /* resolve the next PATH entry */ }
|
|
219
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Same native stdio/environment and exit result as spawnSync, with a live child identity before it exits. */
|
|
225
|
+
export async function runClaudeLaunch(launch: Launch, options: { cwd: string; env: NodeJS.ProcessEnv; stateDir: string; record?: ClaudeLaunchRecord }): Promise<{ status: number | null; signal: NodeJS.Signals | null; error?: Error }> {
|
|
226
|
+
const direct = directNativeExecutable(launch.cmd, options.cwd, options.env);
|
|
227
|
+
return new Promise(resolveResult => {
|
|
228
|
+
let attestation: Promise<unknown> | undefined;
|
|
229
|
+
let childSignature: string | undefined;
|
|
230
|
+
const child = spawn(launch.cmd, launch.args, { cwd: options.cwd, env: options.env, stdio: "inherit" });
|
|
231
|
+
child.once("spawn", () => {
|
|
232
|
+
if (!options.record || !child.pid) return;
|
|
233
|
+
const unavailable = (reason: string) => console.error(`Native identity and crash cleanup disabled for this launch; managed hook record stays current. ${reason}.`);
|
|
234
|
+
if (!direct) { unavailable("native executable is a script or an unverified interpreter"); return; }
|
|
235
|
+
if (!options.record.launcherSignature) { unavailable("launcher identity could not be verified"); return; }
|
|
236
|
+
const signature = processSignature(child.pid);
|
|
237
|
+
if (!signature) { unavailable("native process identity could not be verified"); return; }
|
|
238
|
+
childSignature = signature;
|
|
239
|
+
attestation = recordClaudeNative(options.stateDir, options.record, child.pid, signature, warning => console.error(warning)).catch(() => undefined);
|
|
240
|
+
});
|
|
241
|
+
child.once("error", error => resolveResult({ status: null, signal: null, error }));
|
|
242
|
+
child.once("exit", async (status, signal) => {
|
|
243
|
+
await attestation;
|
|
244
|
+
if (options.record) {
|
|
245
|
+
const saved = readClaudeNative(options.stateDir, options.record);
|
|
246
|
+
if (saved && typeof child.pid === "number" && saved.nativePid === child.pid && saved.nativeSignature === childSignature) removeClaudeNative(options.stateDir, options.record);
|
|
247
|
+
} // this child's observed exit, after its last bounded write
|
|
248
|
+
resolveResult({ status, signal });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
43
251
|
}
|
|
44
252
|
|
|
45
253
|
export interface StatusLineTee {
|
|
@@ -61,22 +269,22 @@ export function statusLineSettings(tee: StatusLineTee): string {
|
|
|
61
269
|
return JSON.stringify({ statusLine: { type: "command", command, refreshInterval: tee.original?.refreshInterval ?? 10, ...(tee.original?.padding !== undefined ? { padding: tee.original.padding } : {}) } });
|
|
62
270
|
}
|
|
63
271
|
|
|
64
|
-
/**
|
|
272
|
+
/** Shared facts, native observation and permission hook transport. */
|
|
65
273
|
export interface FactsHook {
|
|
66
274
|
script: string;
|
|
67
275
|
stateDir: string;
|
|
68
276
|
/** The same hook transport can observe native turns without injecting facts. */
|
|
69
|
-
purpose?: "facts" | "idle" | "facts-and-idle";
|
|
277
|
+
purpose?: "facts" | "idle" | "facts-and-idle" | "permission";
|
|
70
278
|
/** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
|
|
71
279
|
observeNative?: boolean;
|
|
72
280
|
}
|
|
73
281
|
|
|
74
|
-
/** Shared by actual launch and
|
|
75
|
-
export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook
|
|
282
|
+
/** Shared by actual launch and preview; permissions are always enabled, advisory facts remain disabled. */
|
|
283
|
+
export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook {
|
|
76
284
|
const facts = config.coordination === "turn-free";
|
|
77
285
|
const idle = config.task_sweep?.enabled === true;
|
|
78
286
|
const observeNative = facts || idle || config.roles?.claude?.includes("conductor") === true;
|
|
79
|
-
return
|
|
287
|
+
return { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : idle || observeNative ? "idle" : "permission", ...(observeNative ? { observeNative: true } : {}) };
|
|
80
288
|
}
|
|
81
289
|
|
|
82
290
|
/**
|
|
@@ -84,11 +292,11 @@ export function claudeObservationHooks(config: { coordination?: string; task_swe
|
|
|
84
292
|
* facts hook before and after every tool call and at the end of each turn (issue #108; the turn end is the quiescence
|
|
85
293
|
* evidence of issue #107).
|
|
86
294
|
*/
|
|
87
|
-
export function sessionSettings(tee
|
|
88
|
-
const settings = JSON.parse(statusLineSettings(tee)) as Record<string, unknown>;
|
|
295
|
+
export function sessionSettings(tee?: StatusLineTee, facts?: FactsHook): string {
|
|
296
|
+
const settings = (tee ? JSON.parse(statusLineSettings(tee)) : {}) as Record<string, unknown>;
|
|
89
297
|
if (facts) {
|
|
90
|
-
const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} bun ${sh(facts.script)}`, timeout: 5 }];
|
|
91
|
-
settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] };
|
|
298
|
+
const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} AGENTHUB_HOOK_PURPOSE=${sh(facts.purpose ?? "facts")} bun ${sh(facts.script)}`, timeout: 5 }];
|
|
299
|
+
settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], ...(facts.purpose !== "permission" ? { PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] } : {}) };
|
|
92
300
|
if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
|
|
93
301
|
}
|
|
94
302
|
return JSON.stringify(settings);
|
|
@@ -97,7 +305,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
|
|
|
97
305
|
export function buildLaunch(
|
|
98
306
|
tool: "claude" | "codex",
|
|
99
307
|
userArgs: string[],
|
|
100
|
-
ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook },
|
|
308
|
+
ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
|
|
101
309
|
): Launch {
|
|
102
310
|
// Hub-level switches are consumed here; everything else passes through to the tool.
|
|
103
311
|
const passthrough = userArgs.filter((a) => !["--unattended", "--safe", "--new"].includes(a));
|
|
@@ -109,17 +317,62 @@ export function buildLaunch(
|
|
|
109
317
|
if (tool === "claude") {
|
|
110
318
|
// `--settings` takes one value, so a user-supplied one wins and Claude has no quota source for that session.
|
|
111
319
|
const own = passthrough.some((a) => a === "--settings" || a.startsWith("--settings="));
|
|
112
|
-
|
|
320
|
+
let settingsArgs = ctx.statusLine && !own ? ["--settings", sessionSettings(ctx.statusLine, ctx.facts)] : [];
|
|
321
|
+
let nativeArgs = passthrough;
|
|
322
|
+
let settingsFile: string | undefined;
|
|
323
|
+
if (own) {
|
|
324
|
+
if (passthrough.filter(a => a === "--settings" || a.startsWith("--settings=")).length > 1) throw new Error("pass --settings only once so the hub permission hook stays installed");
|
|
325
|
+
const index = passthrough.findIndex(a => a === "--settings" || a.startsWith("--settings="));
|
|
326
|
+
const arg = passthrough[index]!;
|
|
327
|
+
const value = arg === "--settings" ? passthrough[index + 1] : arg.slice("--settings=".length);
|
|
328
|
+
if (!value) throw new Error("--settings requires a JSON object or readable file so the hub permission hook can be installed");
|
|
329
|
+
let caller: Record<string, unknown>;
|
|
330
|
+
try {
|
|
331
|
+
caller = JSON.parse(value.trim().startsWith("{") ? value : readFileSync(resolve(value), "utf8"));
|
|
332
|
+
if (!caller || typeof caller !== "object" || Array.isArray(caller)) throw new Error("not an object");
|
|
333
|
+
} catch { throw new Error("cannot read --settings as a JSON object; the hub permission hook must be installed"); }
|
|
334
|
+
if (ctx.facts && caller.disableAllHooks === true) throw new Error("--settings disableAllHooks prevents the required hub permission hook; enable hooks to launch");
|
|
335
|
+
const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
|
|
336
|
+
const callerHooks = caller.hooks;
|
|
337
|
+
if (callerHooks !== undefined && (!callerHooks || typeof callerHooks !== "object" || Array.isArray(callerHooks))) throw new Error("--settings hooks must be an object");
|
|
338
|
+
const hooks = { ...(callerHooks as Record<string, unknown> | undefined) };
|
|
339
|
+
for (const [event, entries] of Object.entries(injected.hooks ?? {})) {
|
|
340
|
+
const existing = hooks[event];
|
|
341
|
+
if (existing !== undefined && !Array.isArray(existing)) throw new Error(`--settings ${event} hooks must be an array`);
|
|
342
|
+
hooks[event] = [...(existing as unknown[] | undefined ?? []), ...(entries as unknown[])];
|
|
343
|
+
}
|
|
344
|
+
settingsArgs = ["--settings", JSON.stringify({ ...caller, ...(ctx.facts ? { hooks } : {}) })];
|
|
345
|
+
nativeArgs = passthrough.filter((_, i) => i !== index && (arg !== "--settings" || i !== index + 1));
|
|
346
|
+
} else if (ctx.facts && !ctx.statusLine) {
|
|
347
|
+
const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
|
|
348
|
+
settingsArgs = ["--settings", JSON.stringify({ hooks: injected.hooks })];
|
|
349
|
+
}
|
|
350
|
+
if (settingsArgs.length && !ctx.preview) {
|
|
351
|
+
const stateDir = ctx.facts?.stateDir ?? ctx.statusLine?.stateDir;
|
|
352
|
+
if (!stateDir) throw new Error("Claude session settings require the hub state directory");
|
|
353
|
+
const file = join(stateDir, `claude-settings-${randomUUID()}.json`);
|
|
354
|
+
try {
|
|
355
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
356
|
+
writeFileSync(`${file}.tmp`, settingsArgs[1]!, { mode: 0o600, flag: "wx" });
|
|
357
|
+
renameSync(`${file}.tmp`, file);
|
|
358
|
+
} catch {
|
|
359
|
+
try { unlinkSync(`${file}.tmp`); } catch { /* no temp file was written */ }
|
|
360
|
+
throw new Error("cannot write private Claude session settings in the hub state directory");
|
|
361
|
+
}
|
|
362
|
+
settingsArgs[1] = file;
|
|
363
|
+
settingsFile = file;
|
|
364
|
+
}
|
|
113
365
|
const notes = [
|
|
114
366
|
unattended ? UNATTENDED_WARNING : "",
|
|
115
367
|
ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : "",
|
|
116
|
-
ctx.facts && own && ctx.facts.purpose !== "idle" ? "note: you passed --settings, so the hub's turn-free facts hooks are off for this session: Claude will not see the other agents' changes at its tool calls." : "",
|
|
117
|
-
ctx.facts && own && (ctx.facts.purpose === "idle" || ctx.facts.purpose === "facts-and-idle") ? "note: you passed --settings, so native idle observation hooks are off for this session: task idle sweeps cannot verify Claude between turns." : "",
|
|
118
|
-
ctx.facts?.observeNative && own ? "note: you passed --settings, so native session/turn observation hooks are off: completed turns and supervision tokens may be unavailable." : "",
|
|
119
368
|
].filter(Boolean);
|
|
120
369
|
return {
|
|
121
370
|
cmd: "claude",
|
|
122
|
-
args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...
|
|
371
|
+
args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...settingsArgs, ...nativeArgs],
|
|
372
|
+
...(settingsFile ? { settingsFile } : {}),
|
|
373
|
+
permissionHook: !!ctx.facts,
|
|
374
|
+
...(ctx.facts ? { hookPurpose: ctx.facts.purpose ?? "facts" } : {}),
|
|
375
|
+
unattended,
|
|
123
376
|
...(notes.length ? { warning: notes.join("\n") } : {}),
|
|
124
377
|
};
|
|
125
378
|
}
|
|
@@ -127,6 +380,7 @@ export function buildLaunch(
|
|
|
127
380
|
return {
|
|
128
381
|
cmd: ctx.codexBin ?? "codex",
|
|
129
382
|
args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
|
|
383
|
+
unattended,
|
|
130
384
|
...warning,
|
|
131
385
|
};
|
|
132
386
|
}
|