@staix/agent-hub 0.12.20 → 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 +22 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/bus.md +5 -2
- package/docs/agent-notes/daemon.md +8 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +2 -1
- package/docs/operations.md +271 -46
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +45 -10
- package/docs/smoke.md +79 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
- package/docs/verified.json +28 -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 +67 -6
- package/src/adapters/codex-appserver.ts +67 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +58 -4
- 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 +10 -0
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +96 -17
- package/src/cli/main.ts +159 -18
- package/src/cli/preview.ts +13 -3
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-runtime.ts +67 -18
- package/src/cli/upgrade.ts +81 -9
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +19 -4
- 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 +346 -40
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +2 -1
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +21 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +70 -43
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/tools.ts +14 -10
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +83 -6
- package/src/ui/task-progress.ts +33 -0
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") 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", "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,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
|
|
|
@@ -40,6 +43,36 @@ export interface Launch {
|
|
|
40
43
|
cmd: string;
|
|
41
44
|
args: string[];
|
|
42
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);
|
|
43
76
|
}
|
|
44
77
|
|
|
45
78
|
export interface StatusLineTee {
|
|
@@ -61,22 +94,22 @@ export function statusLineSettings(tee: StatusLineTee): string {
|
|
|
61
94
|
return JSON.stringify({ statusLine: { type: "command", command, refreshInterval: tee.original?.refreshInterval ?? 10, ...(tee.original?.padding !== undefined ? { padding: tee.original.padding } : {}) } });
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
/**
|
|
97
|
+
/** Shared facts, native observation and permission hook transport. */
|
|
65
98
|
export interface FactsHook {
|
|
66
99
|
script: string;
|
|
67
100
|
stateDir: string;
|
|
68
101
|
/** The same hook transport can observe native turns without injecting facts. */
|
|
69
|
-
purpose?: "facts" | "idle" | "facts-and-idle";
|
|
102
|
+
purpose?: "facts" | "idle" | "facts-and-idle" | "permission";
|
|
70
103
|
/** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
|
|
71
104
|
observeNative?: boolean;
|
|
72
105
|
}
|
|
73
106
|
|
|
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
|
|
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 {
|
|
76
109
|
const facts = config.coordination === "turn-free";
|
|
77
110
|
const idle = config.task_sweep?.enabled === true;
|
|
78
111
|
const observeNative = facts || idle || config.roles?.claude?.includes("conductor") === true;
|
|
79
|
-
return
|
|
112
|
+
return { ...paths, purpose: facts ? (idle ? "facts-and-idle" : "facts") : idle || observeNative ? "idle" : "permission", ...(observeNative ? { observeNative: true } : {}) };
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
/**
|
|
@@ -84,11 +117,11 @@ export function claudeObservationHooks(config: { coordination?: string; task_swe
|
|
|
84
117
|
* facts hook before and after every tool call and at the end of each turn (issue #108; the turn end is the quiescence
|
|
85
118
|
* evidence of issue #107).
|
|
86
119
|
*/
|
|
87
|
-
export function sessionSettings(tee
|
|
88
|
-
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>;
|
|
89
122
|
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 }] };
|
|
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 }] } : {}) };
|
|
92
125
|
if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
|
|
93
126
|
}
|
|
94
127
|
return JSON.stringify(settings);
|
|
@@ -97,7 +130,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
|
|
|
97
130
|
export function buildLaunch(
|
|
98
131
|
tool: "claude" | "codex",
|
|
99
132
|
userArgs: string[],
|
|
100
|
-
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 },
|
|
101
134
|
): Launch {
|
|
102
135
|
// Hub-level switches are consumed here; everything else passes through to the tool.
|
|
103
136
|
const passthrough = userArgs.filter((a) => !["--unattended", "--safe", "--new"].includes(a));
|
|
@@ -109,17 +142,62 @@ export function buildLaunch(
|
|
|
109
142
|
if (tool === "claude") {
|
|
110
143
|
// `--settings` takes one value, so a user-supplied one wins and Claude has no quota source for that session.
|
|
111
144
|
const own = passthrough.some((a) => a === "--settings" || a.startsWith("--settings="));
|
|
112
|
-
|
|
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
|
+
}
|
|
113
190
|
const notes = [
|
|
114
191
|
unattended ? UNATTENDED_WARNING : "",
|
|
115
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)." : "",
|
|
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
193
|
].filter(Boolean);
|
|
120
194
|
return {
|
|
121
195
|
cmd: "claude",
|
|
122
|
-
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,
|
|
123
201
|
...(notes.length ? { warning: notes.join("\n") } : {}),
|
|
124
202
|
};
|
|
125
203
|
}
|
|
@@ -127,6 +205,7 @@ export function buildLaunch(
|
|
|
127
205
|
return {
|
|
128
206
|
cmd: ctx.codexBin ?? "codex",
|
|
129
207
|
args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
|
|
208
|
+
unattended,
|
|
130
209
|
...warning,
|
|
131
210
|
};
|
|
132
211
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
2
3
|
import { currentRouting } from "../hub/routing.ts";
|
|
3
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
5
|
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -10,13 +11,14 @@ import { loadConfig } from "../hub/daemon.ts";
|
|
|
10
11
|
import { factsHook } from "./facts-hook.ts";
|
|
11
12
|
import { projectContext, realPath } from "../hub/project.ts";
|
|
12
13
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
13
|
-
import { inspectProject,
|
|
14
|
+
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
15
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
14
16
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
15
17
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
16
18
|
import { MemoryClient } from "../memory/client.ts";
|
|
17
19
|
import { init, planInit } from "./init.ts";
|
|
18
20
|
import { launcherPreview } from "./preview.ts";
|
|
19
|
-
import { buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
21
|
+
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
20
22
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
21
23
|
import { CLASSES } from "../hub/board.ts";
|
|
22
24
|
import { VERSION } from "../version.ts";
|
|
@@ -33,13 +35,16 @@ import { setupOllamaModel } from "./models-setup.ts";
|
|
|
33
35
|
import { unknownContext } from "../hub/context-window.ts";
|
|
34
36
|
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
35
37
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
38
|
+
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
|
|
39
|
+
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
40
|
+
import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
36
41
|
import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
|
|
37
42
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
38
43
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
39
44
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
40
45
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
46
|
import { runConsole } from "./console.ts";
|
|
42
|
-
import { resolveColor } from "./console-state.ts";
|
|
47
|
+
import { permissionBoundary, resolveColor } from "./console-state.ts";
|
|
43
48
|
import { renderHelp } from "./help.ts";
|
|
44
49
|
import { renderTailEvent } from "./tail-render.ts";
|
|
45
50
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -97,6 +102,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
|
97
102
|
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
98
103
|
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
99
104
|
if (identity.role === "tools") audit("run");
|
|
105
|
+
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
100
106
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
101
107
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
102
108
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
@@ -108,6 +114,13 @@ function registeredProject(): Project {
|
|
|
108
114
|
finally { registry.close(); }
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/** #247: this project's registration for its research records; writing needs `research.enabled` in its config. */
|
|
118
|
+
function researchProject(writes = true): Project {
|
|
119
|
+
const project = matchingProject() ?? fail("this project has no hub registration; run ahub up here once first");
|
|
120
|
+
if (writes && !loadConfig(cwd).research.enabled) fail('research records are off for this project; set "research": { "enabled": true } in .agenthub/config.json first');
|
|
121
|
+
return project;
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
/** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
|
|
112
125
|
function matchingProject(): Project | undefined {
|
|
113
126
|
const registry = new Registry();
|
|
@@ -124,10 +137,13 @@ function resetLockFree(): void {
|
|
|
124
137
|
/** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
|
|
125
138
|
function liveManifest(): boolean {
|
|
126
139
|
const control = readControl(stateDir);
|
|
127
|
-
if (control &&
|
|
140
|
+
if (control && processLiveness(control.pid, control.pidSignature) !== "gone") return true;
|
|
141
|
+
// A signed manifest is this release's daemon, which writes no hub.pid: one beside it is an older run's leftover.
|
|
142
|
+
if (control?.pidSignature) return false;
|
|
128
143
|
let text: string;
|
|
144
|
+
// Only daemons up to 0.12.20 wrote hub.pid (#226): an unsigned legacy record.
|
|
129
145
|
try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
|
|
130
|
-
if (/^\d+$/.test(text)) return
|
|
146
|
+
if (/^\d+$/.test(text)) return processLiveness(Number(text)) !== "gone";
|
|
131
147
|
// Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
|
|
132
148
|
return !control;
|
|
133
149
|
}
|
|
@@ -217,17 +233,16 @@ function hubDaemonCommand(pid: number, root: string): string | undefined {
|
|
|
217
233
|
|
|
218
234
|
/** Live PID candidates for a registration whose project root is gone: the claim, the manifest, the legacy pid file. */
|
|
219
235
|
function orphanPids(project: Project): number[] {
|
|
236
|
+
// #226: a recorded pid counts only while its signature (when one was recorded) still names its process.
|
|
220
237
|
const candidates = new Set<number>();
|
|
221
|
-
if (typeof project.pid === "number") candidates.add(project.pid);
|
|
238
|
+
if (typeof project.pid === "number" && processLiveness(project.pid, project.pidSignature) === "live") candidates.add(project.pid);
|
|
222
239
|
const control = readControl(project.stateDir);
|
|
223
|
-
if (control && Number.isSafeInteger(control.pid)) candidates.add(control.pid!);
|
|
240
|
+
if (control && Number.isSafeInteger(control.pid) && processLiveness(control.pid, control.pidSignature) === "live") candidates.add(control.pid!);
|
|
224
241
|
try {
|
|
225
242
|
const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
|
|
226
243
|
if (Number.isSafeInteger(pid) && pid > 0) candidates.add(pid);
|
|
227
244
|
} catch { /* no legacy pid file */ }
|
|
228
|
-
return [...candidates].filter((pid) =>
|
|
229
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
230
|
-
});
|
|
245
|
+
return [...candidates].filter((pid) => processLiveness(pid) === "live");
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
@@ -525,10 +540,6 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
525
540
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
526
541
|
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
527
542
|
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
528
|
-
// Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
|
|
529
|
-
const file = join(stateDir, "claude-launch.json");
|
|
530
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
|
|
531
|
-
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
532
543
|
}
|
|
533
544
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
534
545
|
// project local, then project, then user.
|
|
@@ -542,9 +553,26 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
542
553
|
}
|
|
543
554
|
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
544
555
|
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
556
|
+
let previousLaunch: unknown;
|
|
557
|
+
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
545
558
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
546
|
-
|
|
547
|
-
|
|
559
|
+
let result: ReturnType<typeof spawnSync>;
|
|
560
|
+
try {
|
|
561
|
+
if (control?.instanceId) {
|
|
562
|
+
// Publish only the final launch's installed hook and effective permission flags.
|
|
563
|
+
const file = join(stateDir, "claude-launch.json");
|
|
564
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true }), { mode: 0o600 });
|
|
565
|
+
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
566
|
+
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
567
|
+
}
|
|
568
|
+
if (launch.warning) console.error(launch.warning);
|
|
569
|
+
const env = nativeLaunchEnv("claude", childEnv());
|
|
570
|
+
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
571
|
+
} finally {
|
|
572
|
+
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
573
|
+
}
|
|
574
|
+
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
575
|
+
process.exit(result.status ?? 1);
|
|
548
576
|
},
|
|
549
577
|
|
|
550
578
|
codex: async () => {
|
|
@@ -561,7 +589,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
561
589
|
const before = readControl(stateDir);
|
|
562
590
|
if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
|
|
563
591
|
const hub = await connect();
|
|
564
|
-
const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
592
|
+
const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
565
593
|
hub.close();
|
|
566
594
|
if (!res.ok) fail(res.error);
|
|
567
595
|
const control = readControl(stateDir);
|
|
@@ -766,6 +794,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
766
794
|
if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
|
|
767
795
|
if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
|
|
768
796
|
if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
|
|
797
|
+
if (sub === "label") {
|
|
798
|
+
// #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
|
|
799
|
+
const { one, rest: positional } = takeFlags(rest, ["--created"], []);
|
|
800
|
+
const id = Number(positional[0]), label = positional[1] as Label;
|
|
801
|
+
if (positional.length !== 2 || !/^[1-9]\d*$/.test(positional[0]!) || !Number.isSafeInteger(id) || !(LABELS as readonly string[]).includes(label)) fail(`usage: ahub task label <id> ${LABELS.join("|")} [--created <time from ahub research export>]`);
|
|
802
|
+
const project = researchProject();
|
|
803
|
+
let record: TaskRecord;
|
|
804
|
+
try { record = labelTarget(readStores(project.id), readEvents(join(stateDir, "events.jsonl")), id, one["--created"]); } catch (error) { fail((error as Error).message); }
|
|
805
|
+
appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
|
|
806
|
+
return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
|
|
807
|
+
}
|
|
769
808
|
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
|
|
770
809
|
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
771
810
|
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
@@ -814,6 +853,24 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
814
853
|
pause: () => hold("pause"),
|
|
815
854
|
resume: () => hold("resume"),
|
|
816
855
|
|
|
856
|
+
permission: async () => {
|
|
857
|
+
const positional = args.filter(arg => arg !== "--yes");
|
|
858
|
+
const [peer, mode] = positional;
|
|
859
|
+
if (positional.length > 2 || positional.some(arg => arg.startsWith("--")) || (mode !== undefined && !isPermissionMode(mode))) fail("usage: ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]");
|
|
860
|
+
if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
861
|
+
if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer)}; approval prompts are disabled`);
|
|
862
|
+
const hub = await connect();
|
|
863
|
+
try {
|
|
864
|
+
const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
|
|
865
|
+
if (reply.ok === false) {
|
|
866
|
+
const error = String(reply.error ?? "permission request refused");
|
|
867
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "permission"/i.test(error) ? `${error}; upgrade the running hub to use ahub permission` : error);
|
|
868
|
+
}
|
|
869
|
+
if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
|
|
870
|
+
else console.log(`${peer}: ${reply.permissionMode}`);
|
|
871
|
+
} finally { hub.close(); }
|
|
872
|
+
},
|
|
873
|
+
|
|
817
874
|
permit: async () => {
|
|
818
875
|
const [id, option] = args;
|
|
819
876
|
if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
|
|
@@ -831,6 +888,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
831
888
|
if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
|
|
832
889
|
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
833
890
|
if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
|
|
891
|
+
for (const pending of status.permissionDefaults ?? []) console.log(` permission default ${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`);
|
|
834
892
|
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
835
893
|
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
836
894
|
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
@@ -846,6 +904,89 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
846
904
|
export: () => {
|
|
847
905
|
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
848
906
|
},
|
|
907
|
+
bench: async () => {
|
|
908
|
+
// #251: benchmark suites run against the peers attached here, and their comparison across arms.
|
|
909
|
+
const json = args.includes("--json");
|
|
910
|
+
const [sub, ...rest] = args.filter((a) => a !== "--json");
|
|
911
|
+
if (sub === "run") {
|
|
912
|
+
const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
|
|
913
|
+
const suite = positional[0];
|
|
914
|
+
const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
|
|
915
|
+
if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
|
|
916
|
+
fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
|
|
917
|
+
}
|
|
918
|
+
await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
|
|
919
|
+
let interrupted = false;
|
|
920
|
+
const stop = () => { interrupted = true; console.log("stopping at the next check: the attempt in progress is recorded as interrupted (a task still open is named)"); };
|
|
921
|
+
process.on("SIGINT", stop);
|
|
922
|
+
try {
|
|
923
|
+
const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
|
|
924
|
+
return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
|
|
925
|
+
} finally { process.off("SIGINT", stop); }
|
|
926
|
+
}
|
|
927
|
+
const runs = readRuns();
|
|
928
|
+
if (sub === undefined || sub === "list") return console.log(json ? JSON.stringify(runs.map(runSummary), null, 2) : formatRuns(runs).join("\n"));
|
|
929
|
+
if (sub === "status") {
|
|
930
|
+
const live = runs.filter((r) => r.state === "running" || r.state === "unknown");
|
|
931
|
+
return console.log(json ? JSON.stringify(live.map(runSummary), null, 2) : live.length ? formatRuns(live).join("\n") : "no benchmark run is running");
|
|
932
|
+
}
|
|
933
|
+
if (sub === "report") {
|
|
934
|
+
const run = runs.find((r) => r.header.run === rest[0]) ?? fail(`usage: ahub bench report <run> (ahub bench list shows the runs)`);
|
|
935
|
+
const r = benchReport([run]);
|
|
936
|
+
return console.log(json ? JSON.stringify(r, null, 2) : formatBenchReport(r).join("\n"));
|
|
937
|
+
}
|
|
938
|
+
if (sub === "compare") {
|
|
939
|
+
const mixed = rest.includes("--mixed");
|
|
940
|
+
const { one, rest: picks } = takeFlags(rest.filter((a) => a !== "--mixed"), ["--suite"], []);
|
|
941
|
+
const scoped = one["--suite"] ? runs.filter((r) => r.header.suite === one["--suite"]) : runs;
|
|
942
|
+
const groups = picks.map((pick) => {
|
|
943
|
+
const byRun = scoped.filter((r) => r.header.run === pick);
|
|
944
|
+
const chosen = byRun.length ? byRun : armRuns(scoped, pick);
|
|
945
|
+
if (!chosen.length) fail(`no run, or arm with a run that is over, named ${pick}${one["--suite"] ? ` in suite ${one["--suite"]}` : ""}`);
|
|
946
|
+
return { arm: byRun.length ? `${pick} [${byRun[0]!.header.arm}]` : pick, runs: chosen };
|
|
947
|
+
});
|
|
948
|
+
if (groups.length < 2) fail("usage: ahub bench compare <run|arm> <run|arm>... [--suite <name>] [--mixed] [--json]");
|
|
949
|
+
const differs = sameSuite(groups);
|
|
950
|
+
if (differs && !mixed) fail(`${differs}; compare runs of one suite file (name runs by id, or --suite when the names differ), or pass --mixed to compare them anyway`);
|
|
951
|
+
const c = benchCompare(groups);
|
|
952
|
+
return console.log(json ? JSON.stringify(c, null, 2) : formatCompare(c).join("\n"));
|
|
953
|
+
}
|
|
954
|
+
if (sub === "export") {
|
|
955
|
+
const format = rest.includes("--format") ? rest[rest.indexOf("--format") + 1] : "jsonl";
|
|
956
|
+
if (format !== "jsonl" && format !== "csv") fail("--format takes jsonl or csv");
|
|
957
|
+
if (format === "csv") return void process.stdout.write(benchCsv(runs));
|
|
958
|
+
for (const r of runs) for (const record of [r.header, ...r.attempts, ...(r.end ? [r.end] : [])]) console.log(JSON.stringify(record));
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
|
|
962
|
+
},
|
|
963
|
+
research: () => {
|
|
964
|
+
// #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
|
|
965
|
+
const [sub] = args;
|
|
966
|
+
// Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
|
|
967
|
+
const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
|
|
968
|
+
for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
|
|
969
|
+
if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
|
|
970
|
+
if (valued.has(args[i]!)) i++;
|
|
971
|
+
}
|
|
972
|
+
if (sub === "backfill") {
|
|
973
|
+
const project = researchProject();
|
|
974
|
+
const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
|
|
975
|
+
return console.log(`${written} research record${written === 1 ? "" : "s"} written from this project's events`);
|
|
976
|
+
}
|
|
977
|
+
const all = args.includes("--all");
|
|
978
|
+
const records = readStores(all ? undefined : researchProject(false).id);
|
|
979
|
+
const from = since();
|
|
980
|
+
if (sub === "export") {
|
|
981
|
+
const format = args[args.indexOf("--format") + 1] ?? "jsonl";
|
|
982
|
+
if (args.includes("--format") && !["jsonl", "csv"].includes(format)) fail("--format takes jsonl or csv");
|
|
983
|
+
const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
|
|
984
|
+
return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
|
985
|
+
}
|
|
986
|
+
if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
|
|
987
|
+
const r = researchReport(records, from);
|
|
988
|
+
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
989
|
+
},
|
|
849
990
|
report: () => {
|
|
850
991
|
const by = args.indexOf("--by");
|
|
851
992
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
@@ -1020,7 +1161,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1020
1161
|
// The prefix tells a concurrent reset's claim from a daemon's.
|
|
1021
1162
|
const claim = `reset-${randomUUID()}`;
|
|
1022
1163
|
try {
|
|
1023
|
-
if (!registry.claim(project.id, claim, process.pid)) {
|
|
1164
|
+
if (!registry.claim(project.id, claim, process.pid, processSignature(process.pid))) {
|
|
1024
1165
|
const holder = registry.get(project.id);
|
|
1025
1166
|
throw new Error(holder?.instanceId?.startsWith("reset-") ? `another ahub reset of this project is running (pid ${holder.pid}); nothing was reset` : "a hub started after the stop; nothing was reset, run ahub reset again");
|
|
1026
1167
|
}
|
package/src/cli/preview.ts
CHANGED
|
@@ -38,9 +38,19 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
38
38
|
}
|
|
39
39
|
// Build with original inputs so channel selection and owned-flag validation match normal launch.
|
|
40
40
|
const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
41
|
-
launch = buildLaunch(tool, args, { unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
launch = buildLaunch(tool, args, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
42
|
+
// Merged caller settings change the native argument count. Rebuild a redacted preview,
|
|
43
|
+
// retaining the managed hooks but never serializing caller settings or their file path.
|
|
44
|
+
const safeArgs = args.map((arg, i) => {
|
|
45
|
+
if (args[i - 1] === "--settings") return "{}";
|
|
46
|
+
if (arg.startsWith("--settings=")) return "--settings={}";
|
|
47
|
+
if (["--unattended", "--safe", "--new"].includes(arg)) return arg;
|
|
48
|
+
return publicArg(arg);
|
|
49
|
+
});
|
|
50
|
+
const publicLaunch = buildLaunch(tool, safeArgs, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir,
|
|
51
|
+
...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, facts });
|
|
52
|
+
publicLaunch.args.splice(0, 2, ...launch.args.slice(0, 2)); // channel selection was verified against the original MCP config
|
|
53
|
+
launch = publicLaunch;
|
|
44
54
|
} else if (tool === "codex") {
|
|
45
55
|
// A fresh native proxy is assigned by start, never guessed from an old status file.
|
|
46
56
|
launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
3
|
|
|
4
|
-
export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
6
6
|
|
|
7
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
@@ -26,6 +26,7 @@ export function peerLine(id: string, p: PeerRow): string {
|
|
|
26
26
|
(p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
|
|
27
27
|
(p.attached === false ? " disconnected" : "") +
|
|
28
28
|
(p.toolsOnly ? ` ${p.toolsOnly}` : "") +
|
|
29
|
+
(p.permissionMode && p.permissionMode !== "ask" ? ` permission: ${p.permissionMode}` : "") +
|
|
29
30
|
(p.paused ? ` (${p.paused})` : "") +
|
|
30
31
|
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
31
32
|
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|