@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.
- package/CHANGELOG.md +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- 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 {
|
|
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
|
-
/**
|
|
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
|
|
73
|
-
export function claudeObservationHooks(config: { coordination?: string; task_sweep?: { enabled: boolean }; roles?: Record<string, string[]> }, paths: Pick<FactsHook, "script" | "stateDir">): FactsHook
|
|
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
|
|
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
|
|
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
|
-
|
|
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"] : []), ...
|
|
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
|
}
|