@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
package/src/cli/help.ts CHANGED
@@ -7,7 +7,7 @@ export interface HelpEntry { section: string; usage: string; description: string
7
7
  const SECTIONS: [string, [usage: string, description: string][]][] = [
8
8
  ["Projects", [
9
9
  ["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
10
- ["ahub projects [--json]", "list registered projects and live status"],
10
+ ["ahub projects [--json] [--color=auto|always|never]", "list registered projects and live status"],
11
11
  ["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
12
12
  ["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
13
13
  ["ahub init [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
@@ -17,18 +17,18 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
17
17
  ["Daemon and runtime", [
18
18
  ["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
19
19
  ["ahub console [--panels] [--color=auto|always|never]", "enter the human console, leaving the daemon running on exit"],
20
- ["ahub status", "the hub, its peers, model backends and task counts"],
21
- ["ahub status --all", "show every registered project"],
20
+ ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
21
+ ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
22
22
  ["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
23
- ["ahub doctor", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
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"],
23
+ ["ahub doctor [--json] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
24
+ ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "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
  ]],
@@ -36,22 +36,26 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
36
36
  ["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
37
37
  ["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
38
38
  ["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
39
- ["ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi"],
39
+ ["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
40
40
  ["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
41
- ["ahub models setup|status|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
41
+ ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
42
+ ["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
43
+ ["ahub models setup|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
42
44
  ]],
43
45
  ["Messages and approvals", [
44
46
  ["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
47
  ["ahub tail", "live stream of messages, states and permission requests"],
46
48
  ["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"],
47
49
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
50
+ ["ahub settings [list] [--json] | get <key>", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
51
+ ["ahub settings set <key> <value|inherit> [--yes] [--preview] | undo", "human only: change one, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
48
52
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
49
- ["ahub queue list [--peer <id>] [--json]", "inspect durable deliveries"],
50
- ["ahub queue show <delivery-id>", "inspect one delivery and revision"],
53
+ ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
54
+ ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
51
55
  ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
52
56
  ]],
53
57
  ["Tasks and review", [
54
- ["ahub board [state | --ready]", "the task board; --ready: proposed tasks with nothing left to wait for"],
58
+ ["ahub board [state | --ready] [--json] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
55
59
  ["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"],
56
60
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
57
61
  ["ahub task assign <id> <peer>", "give a task to a peer yourself; a task that still waits is reserved for that peer instead"],
@@ -63,15 +67,16 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
63
67
  ["ahub remember <text...>", "save a note to the memory all agents share"],
64
68
  ]],
65
69
  ["Budget", [
66
- ["ahub budget", "quota windows per peer, and who is paused until when"],
70
+ ["ahub budget [--json] [--color=auto|always|never]", "quota windows per peer, and who is paused until when"],
67
71
  ["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
68
72
  ["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
69
- ["ahub budget execution configure <config.json> | status [id] | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
73
+ ["ahub budget execution status [id] [--json] [--color=auto|always|never]", "read execution counters and limits"],
74
+ ["ahub budget execution configure <config.json> | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
70
75
  ]],
71
76
  ["History and reports", [
72
- ["ahub turns [peer] [--limit N]", "recent turns and the files each changed (a git work tree only)"],
77
+ ["ahub turns [peer] [--limit N] [--json] [--color=auto|always|never]", "recent turns and the files each changed (a git work tree only)"],
73
78
  ["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"],
74
- ["ahub report [--since 7d|<iso>] [--by task] [--json]", "turns, tokens, messages, overlaps and task events per period"],
79
+ ["ahub report [--since 7d|<iso>] [--by task] [--json] [--color=auto|always|never]", "turns, tokens, messages, overlaps and task events per period"],
75
80
  ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
76
81
  ["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"],
77
82
  ["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
@@ -89,6 +94,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
89
94
  ]],
90
95
  ["Dashboard", [
91
96
  ["ahub ui [--no-open]", "open the local dashboard (or print its one-time link)"],
97
+ ["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing and start settings"],
92
98
  ["ahub ui --all [--no-open]", "open the unified project dashboard"],
93
99
  ["ahub ui --all --stop", "stop only the dashboard manager"],
94
100
  ["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
@@ -26,7 +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") return "console";
29
+ if (command === "permission" || command === "stop") return "console";
30
30
  if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
31
31
  if (command === "task") {
32
32
  if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
@@ -39,7 +39,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
39
39
  // #251: a run resets the bench project's tree and proposes work; reading results is harmless.
40
40
  // The subcommand is the first argument that is not --json, which the command accepts anywhere.
41
41
  if (command === "bench") return args.find((arg) => arg !== "--json") === "run" ? "console" : "allowed";
42
- if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
42
+ if (command === "budget") return !args.length || args.every((arg) => arg === "--json" || arg === "--full" || arg.startsWith("--color=")) ? "allowed" : "console";
43
43
  if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
44
44
  if (["help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
45
45
  return "console";
@@ -47,7 +47,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
47
47
 
48
48
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
49
49
  export function cliCommandLabel(command: string, args: readonly string[]): string {
50
- 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", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
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", "settings", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
51
51
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "bench"].includes(command)) {
52
52
  const sub = command === "bench" ? args.find((arg) => arg !== "--json") : args[0];
53
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}`;
package/src/cli/init.ts CHANGED
@@ -57,7 +57,7 @@ function changes(cwd: string): PlannedWrite[] {
57
57
  }
58
58
  const ignore = join(cwd, ".gitignore");
59
59
  let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
60
- for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
60
+ for (const entry of [".agenthub/state/", ".agenthub/config.local.json", ".agenthub/routing.local.toml"]) {
61
61
  if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
62
62
  }
63
63
  write(ignore, lines, "ignore runtime state and machine-local configuration");
package/src/cli/launch.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
- import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync } from "node:fs";
3
- import { randomUUID } from "node:crypto";
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";
4
5
  import { resolve, join, basename, dirname, isAbsolute } from "node:path";
5
6
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
6
7
  import { realPath } from "../hub/project.ts";
7
- import { peerChildEnv } from "../hub/child-process.ts";
8
+ import { peerChildEnv, processTable } from "../hub/child-process.ts";
8
9
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
9
10
 
10
11
  /** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
@@ -65,14 +66,188 @@ export function cleanupClaudeSettings(stateDir: string, previous: unknown): void
65
66
  /** Crash fallback only: an absent/unreadable owner identity never certifies that its native launch ended. */
66
67
  export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown, identity = processSignature): void {
67
68
  if (!previous || typeof previous !== "object" || Array.isArray(previous)) return;
68
- const record = previous as Record<string, unknown>;
69
+ const original = previous as Record<string, unknown>;
70
+ const record = readClaudeNative(stateDir, original as ClaudeLaunchRecord) ?? original;
69
71
  if (typeof record.launcherSignature !== "string" || !record.launcherSignature) return;
70
72
  if (processLiveness(record.launcherPid, record.launcherSignature, identity) !== "gone") return;
71
- // ponytail: spawnSync launch records have no independent native identity, so crash-left settings are retained.
72
- // Upgrade path: record the native child's PID and processSignature before admitting crash fallback cleanup.
73
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 (processLiveness(record.nativePid, record.nativeSignature, identity) !== "gone") 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;
75
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
+ });
76
251
  }
77
252
 
78
253
  export interface StatusLineTee {