@staix/agent-hub 0.12.19 → 0.12.21

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 +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
package/src/cli/launch.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
- import { readFileSync } from "node:fs";
3
- import { resolve } from "node:path";
2
+ import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { resolve, join, basename, dirname, isAbsolute } from "node:path";
5
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
6
+ import { realPath } from "../hub/project.ts";
4
7
  import { peerChildEnv } from "../hub/child-process.ts";
5
8
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
6
9
 
@@ -8,6 +11,8 @@ export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
8
11
  export function nativeLaunchEnv(tool: "claude" | "codex" | "pi", source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
9
12
  const env = peerChildEnv(tool, source);
10
13
  delete env.AGENTHUB_UNATTENDED;
14
+ // Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
15
+ if (tool === "claude") env.AGENTHUB_CHANNEL = "1";
11
16
  return env;
12
17
  }
13
18
 
@@ -38,6 +43,36 @@ export interface Launch {
38
43
  cmd: string;
39
44
  args: string[];
40
45
  warning?: string;
46
+ permissionHook?: boolean;
47
+ hookPurpose?: FactsHook["purpose"];
48
+ settingsFile?: string;
49
+ unattended?: boolean;
50
+ }
51
+
52
+ /** Retire only our prior private Claude settings file, after the replacement launch record is committed. */
53
+ export function cleanupClaudeSettings(stateDir: string, previous: unknown): void {
54
+ 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;
55
+ try {
56
+ const root = realPath(stateDir);
57
+ if (realPath(dirname(previous)) !== root) return;
58
+ const stat = lstatSync(previous);
59
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
60
+ if (realPath(previous) !== join(root, basename(previous))) return;
61
+ unlinkSync(previous);
62
+ } catch { /* Missing or unverifiable files remain untouched. */ }
63
+ }
64
+
65
+ /** Crash fallback only: an absent/unreadable owner identity never certifies that its native launch ended. */
66
+ export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown, identity = processSignature): void {
67
+ if (!previous || typeof previous !== "object" || Array.isArray(previous)) return;
68
+ const record = previous as Record<string, unknown>;
69
+ if (typeof record.launcherSignature !== "string" || !record.launcherSignature) return;
70
+ 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
+ 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;
75
+ cleanupClaudeSettings(stateDir, record.settingsFile);
41
76
  }
42
77
 
43
78
  export interface StatusLineTee {
@@ -59,22 +94,22 @@ export function statusLineSettings(tee: StatusLineTee): string {
59
94
  return JSON.stringify({ statusLine: { type: "command", command, refreshInterval: tee.original?.refreshInterval ?? 10, ...(tee.original?.padding !== undefined ? { padding: tee.original.padding } : {}) } });
60
95
  }
61
96
 
62
- /** The turn-free facts hook (issue #108): absolute path of src/cli/facts-hook.ts and the hub's state dir. */
97
+ /** Shared facts, native observation and permission hook transport. */
63
98
  export interface FactsHook {
64
99
  script: string;
65
100
  stateDir: string;
66
101
  /** The same hook transport can observe native turns without injecting facts. */
67
- purpose?: "facts" | "idle" | "facts-and-idle";
102
+ purpose?: "facts" | "idle" | "facts-and-idle" | "permission";
68
103
  /** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
69
104
  observeNative?: boolean;
70
105
  }
71
106
 
72
- /** Shared by actual launch and read-only preview; advisory facts remain disabled. */
73
- export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook | undefined {
107
+ /** Shared by actual launch and preview; permissions are always enabled, advisory facts remain disabled. */
108
+ export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook {
74
109
  const facts = config.coordination === "turn-free";
75
110
  const idle = config.task_sweep?.enabled === true;
76
111
  const observeNative = facts || idle || config.roles?.claude?.includes("conductor") === true;
77
- return facts || idle || observeNative ? { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : "idle", ...(observeNative ? { observeNative: true } : {}) } : undefined;
112
+ return { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : idle || observeNative ? "idle" : "permission", ...(observeNative ? { observeNative: true } : {}) };
78
113
  }
79
114
 
80
115
  /**
@@ -82,11 +117,11 @@ export function claudeObservationHooks(config: { coordination?: string; task_swe
82
117
  * facts hook before and after every tool call and at the end of each turn (issue #108; the turn end is the quiescence
83
118
  * evidence of issue #107).
84
119
  */
85
- export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
86
- const settings = JSON.parse(statusLineSettings(tee)) as Record<string, unknown>;
120
+ export function sessionSettings(tee?: StatusLineTee, facts?: FactsHook): string {
121
+ const settings = (tee ? JSON.parse(statusLineSettings(tee)) : {}) as Record<string, unknown>;
87
122
  if (facts) {
88
- const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} bun ${sh(facts.script)}`, timeout: 5 }];
89
- settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] };
123
+ const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} AGENTHUB_HOOK_PURPOSE=${sh(facts.purpose ?? "facts")} bun ${sh(facts.script)}`, timeout: 5 }];
124
+ settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], ...(facts.purpose !== "permission" ? { PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] } : {}) };
90
125
  if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
91
126
  }
92
127
  return JSON.stringify(settings);
@@ -95,7 +130,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
95
130
  export function buildLaunch(
96
131
  tool: "claude" | "codex",
97
132
  userArgs: string[],
98
- ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook },
133
+ ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee; facts?: FactsHook; preview?: boolean },
99
134
  ): Launch {
100
135
  // Hub-level switches are consumed here; everything else passes through to the tool.
101
136
  const passthrough = userArgs.filter((a) => !["--unattended", "--safe", "--new"].includes(a));
@@ -107,17 +142,62 @@ export function buildLaunch(
107
142
  if (tool === "claude") {
108
143
  // `--settings` takes one value, so a user-supplied one wins and Claude has no quota source for that session.
109
144
  const own = passthrough.some((a) => a === "--settings" || a.startsWith("--settings="));
110
- const tee = ctx.statusLine && !own ? ["--settings", sessionSettings(ctx.statusLine, ctx.facts)] : [];
145
+ let settingsArgs = ctx.statusLine && !own ? ["--settings", sessionSettings(ctx.statusLine, ctx.facts)] : [];
146
+ let nativeArgs = passthrough;
147
+ let settingsFile: string | undefined;
148
+ if (own) {
149
+ 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");
150
+ const index = passthrough.findIndex(a => a === "--settings" || a.startsWith("--settings="));
151
+ const arg = passthrough[index]!;
152
+ const value = arg === "--settings" ? passthrough[index + 1] : arg.slice("--settings=".length);
153
+ if (!value) throw new Error("--settings requires a JSON object or readable file so the hub permission hook can be installed");
154
+ let caller: Record<string, unknown>;
155
+ try {
156
+ caller = JSON.parse(value.trim().startsWith("{") ? value : readFileSync(resolve(value), "utf8"));
157
+ if (!caller || typeof caller !== "object" || Array.isArray(caller)) throw new Error("not an object");
158
+ } catch { throw new Error("cannot read --settings as a JSON object; the hub permission hook must be installed"); }
159
+ if (ctx.facts && caller.disableAllHooks === true) throw new Error("--settings disableAllHooks prevents the required hub permission hook; enable hooks to launch");
160
+ const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
161
+ const callerHooks = caller.hooks;
162
+ if (callerHooks !== undefined && (!callerHooks || typeof callerHooks !== "object" || Array.isArray(callerHooks))) throw new Error("--settings hooks must be an object");
163
+ const hooks = { ...(callerHooks as Record<string, unknown> | undefined) };
164
+ for (const [event, entries] of Object.entries(injected.hooks ?? {})) {
165
+ const existing = hooks[event];
166
+ if (existing !== undefined && !Array.isArray(existing)) throw new Error(`--settings ${event} hooks must be an array`);
167
+ hooks[event] = [...(existing as unknown[] | undefined ?? []), ...(entries as unknown[])];
168
+ }
169
+ settingsArgs = ["--settings", JSON.stringify({ ...caller, ...(ctx.facts ? { hooks } : {}) })];
170
+ nativeArgs = passthrough.filter((_, i) => i !== index && (arg !== "--settings" || i !== index + 1));
171
+ } else if (ctx.facts && !ctx.statusLine) {
172
+ const injected = JSON.parse(sessionSettings(undefined, ctx.facts));
173
+ settingsArgs = ["--settings", JSON.stringify({ hooks: injected.hooks })];
174
+ }
175
+ if (settingsArgs.length && !ctx.preview) {
176
+ const stateDir = ctx.facts?.stateDir ?? ctx.statusLine?.stateDir;
177
+ if (!stateDir) throw new Error("Claude session settings require the hub state directory");
178
+ const file = join(stateDir, `claude-settings-${randomUUID()}.json`);
179
+ try {
180
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
181
+ writeFileSync(`${file}.tmp`, settingsArgs[1]!, { mode: 0o600, flag: "wx" });
182
+ renameSync(`${file}.tmp`, file);
183
+ } catch {
184
+ try { unlinkSync(`${file}.tmp`); } catch { /* no temp file was written */ }
185
+ throw new Error("cannot write private Claude session settings in the hub state directory");
186
+ }
187
+ settingsArgs[1] = file;
188
+ settingsFile = file;
189
+ }
111
190
  const notes = [
112
191
  unattended ? UNATTENDED_WARNING : "",
113
192
  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)." : "",
114
- 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." : "",
115
- 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." : "",
116
- ctx.facts?.observeNative && own ? "note: you passed --settings, so native session/turn observation hooks are off: completed turns and supervision tokens may be unavailable." : "",
117
193
  ].filter(Boolean);
118
194
  return {
119
195
  cmd: "claude",
120
- args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...tee, ...passthrough],
196
+ args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...settingsArgs, ...nativeArgs],
197
+ ...(settingsFile ? { settingsFile } : {}),
198
+ permissionHook: !!ctx.facts,
199
+ ...(ctx.facts ? { hookPurpose: ctx.facts.purpose ?? "facts" } : {}),
200
+ unattended,
121
201
  ...(notes.length ? { warning: notes.join("\n") } : {}),
122
202
  };
123
203
  }
@@ -125,6 +205,7 @@ export function buildLaunch(
125
205
  return {
126
206
  cmd: ctx.codexBin ?? "codex",
127
207
  args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
208
+ unattended,
128
209
  ...warning,
129
210
  };
130
211
  }