@staix/agent-hub 0.12.15 → 0.12.17
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 +17 -0
- package/README.md +18 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +277 -11
- package/docs/quickstart.md +4 -4
- package/docs/security.md +26 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +168 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +18 -6
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +10 -2
- package/src/adapters/local-worker.ts +5 -5
- package/src/adapters/pi.ts +4 -14
- package/src/cli/console-state.ts +213 -0
- package/src/cli/console.ts +201 -0
- package/src/cli/facts-hook.ts +8 -1
- package/src/cli/identity-audit.ts +66 -0
- package/src/cli/identity.ts +54 -0
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +31 -1
- package/src/cli/main.ts +95 -39
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +9 -2
- package/src/hub/bus.ts +82 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +492 -77
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +7 -1
- package/src/hub/hub-tools.ts +14 -1
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +86 -15
- package/src/hub/usage.ts +37 -2
- package/src/models/relay.ts +6 -1
- package/src/pi/launch.ts +21 -0
- package/src/ui/index.html +6 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import type { ControlClient } from "../hub/control-client.ts";
|
|
4
|
+
import { contextLine } from "./status-lines.ts";
|
|
5
|
+
import { renderTailEvent } from "./tail-render.ts";
|
|
6
|
+
import { initialConsoleState, pruneApprovals, reduceConsole, renderConsole, terminalText, wrap } from "./console-state.ts";
|
|
7
|
+
import type { ConsoleEffect, ConsoleEvent } from "./console-state.ts";
|
|
8
|
+
|
|
9
|
+
export interface ConsoleTerminal {
|
|
10
|
+
columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
|
|
11
|
+
write(text: string): void;
|
|
12
|
+
raw(enabled: boolean): void;
|
|
13
|
+
onData(listener: (text: string) => void): () => void;
|
|
14
|
+
onResize(listener: () => void): () => void;
|
|
15
|
+
onSignal(listener: () => void): () => void;
|
|
16
|
+
onError?(listener: (error: Error) => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface ConsoleOptions {
|
|
19
|
+
client: Pick<ControlClient, "request" | "send" | "close" | "onPush" | "onClose">;
|
|
20
|
+
cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
|
|
21
|
+
terminal?: ConsoleTerminal;
|
|
22
|
+
/** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
|
|
23
|
+
runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
|
|
24
|
+
pollMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
|
|
27
|
+
function nativeTerminal(): ConsoleTerminal {
|
|
28
|
+
return {
|
|
29
|
+
get columns() { return process.stdout.columns ?? 80; }, get rows() { return process.stdout.rows ?? 24; },
|
|
30
|
+
get isTTY() { return !!process.stdin.isTTY && !!process.stdout.isTTY; }, get isRaw() { return process.stdin.isRaw; },
|
|
31
|
+
write: text => { process.stdout.write(text); }, raw: enabled => { process.stdin.setRawMode(enabled); },
|
|
32
|
+
onData: listener => {
|
|
33
|
+
const decoder = new StringDecoder("utf8"); const onData = (data: Buffer) => listener(decoder.write(data));
|
|
34
|
+
process.stdin.on("data", onData); process.stdin.resume();
|
|
35
|
+
return () => { process.stdin.off("data", onData); process.stdin.pause(); };
|
|
36
|
+
},
|
|
37
|
+
onResize: listener => { process.stdout.on("resize", listener); return () => { process.stdout.off("resize", listener); }; },
|
|
38
|
+
onError: listener => {
|
|
39
|
+
const rejected = (reason: unknown) => listener(reason instanceof Error ? reason : new Error(String(reason)));
|
|
40
|
+
process.on("uncaughtException", listener); process.on("unhandledRejection", rejected);
|
|
41
|
+
return () => { process.off("uncaughtException", listener); process.off("unhandledRejection", rejected); };
|
|
42
|
+
},
|
|
43
|
+
onSignal: listener => { process.on("SIGINT", listener); process.on("SIGTERM", listener); return () => { process.off("SIGINT", listener); process.off("SIGTERM", listener); }; },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** One terminal client, no daemon ownership. All text crosses terminalText before writing. */
|
|
47
|
+
export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
48
|
+
const { client } = options; const terminal = options.terminal ?? nativeTerminal();
|
|
49
|
+
let state = initialConsoleState(!!options.panels);
|
|
50
|
+
let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
|
|
51
|
+
let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
|
|
52
|
+
let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
|
|
53
|
+
const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
|
|
54
|
+
const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
|
|
55
|
+
let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
|
|
56
|
+
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
57
|
+
const safeWrite = (text: string) => terminal.write(terminalText(text));
|
|
58
|
+
const writeStream = (text: string) => {
|
|
59
|
+
terminal.write(`\x1b[${rows - 3};1H`);
|
|
60
|
+
for (const line of wrap(text, columns)) { safeWrite(line); terminal.write("\r\n"); }
|
|
61
|
+
};
|
|
62
|
+
const stream = (event: ConsoleEvent) => {
|
|
63
|
+
state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
|
|
64
|
+
if (plain) safeWrite(event.text + "\n");
|
|
65
|
+
else if (state.mode === "stream") writeStream(event.text);
|
|
66
|
+
else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
|
|
67
|
+
};
|
|
68
|
+
const draw = () => {
|
|
69
|
+
if (!active || plain) return;
|
|
70
|
+
if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
|
|
71
|
+
else if (state.mode === "stream" && inAlternate) {
|
|
72
|
+
terminal.write(`\x1b[?1049l\x1b[1;${rows - 3}r`); inAlternate = false;
|
|
73
|
+
if (droppedStream) writeStream(`${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.`);
|
|
74
|
+
for (const event of pendingStream) writeStream(event.text);
|
|
75
|
+
pendingStream = []; droppedStream = 0;
|
|
76
|
+
}
|
|
77
|
+
terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 3}r` : "\x1b[r");
|
|
78
|
+
const lines = renderConsole(state, columns, rows);
|
|
79
|
+
const start = state.mode === "stream" ? rows - 2 : 1;
|
|
80
|
+
for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); safeWrite(line); }
|
|
81
|
+
terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(lines.at(-1) ?? "") + 1)}H\x1b[?25h`);
|
|
82
|
+
};
|
|
83
|
+
const stop = (reason?: string) => {
|
|
84
|
+
if (!active) return;
|
|
85
|
+
active = false;
|
|
86
|
+
for (const timer of timers) clearInterval(timer);
|
|
87
|
+
for (const remove of removers) remove();
|
|
88
|
+
cancelChild?.(); cancelChild = undefined;
|
|
89
|
+
client.onPush = priorPush; client.onClose = priorClose;
|
|
90
|
+
if (terminal.isTTY) {
|
|
91
|
+
try { terminal.raw(priorRaw); } finally { terminal.write(RESTORE_CONSOLE + "\r\n"); }
|
|
92
|
+
}
|
|
93
|
+
client.close();
|
|
94
|
+
safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
|
|
95
|
+
resolveDone();
|
|
96
|
+
};
|
|
97
|
+
const refresh = async () => {
|
|
98
|
+
if (polling || !active) return; polling = true;
|
|
99
|
+
try {
|
|
100
|
+
const panels = state.mode === "panels" && !plain;
|
|
101
|
+
const replies = await Promise.all([
|
|
102
|
+
client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
|
|
103
|
+
...(panels ? [client.request({ t: "task", op: "hub_task_list", args: {} }, 3000), client.request({ t: "queue", op: "list" }, 3000), client.request({ t: "task", op: "hub_task_list", args: { ready: true } }, 3000)] : []),
|
|
104
|
+
]);
|
|
105
|
+
if (!active) return;
|
|
106
|
+
const [status, budget, tasks, queue, ready] = replies;
|
|
107
|
+
if (status.status?.peers) state.peers = status.status.peers;
|
|
108
|
+
if (budget.budget) state.budget = budget.budget;
|
|
109
|
+
if (tasks?.ok && state.mode === "panels") { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
|
|
110
|
+
const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
|
|
111
|
+
state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
|
|
112
|
+
} }
|
|
113
|
+
if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
|
|
114
|
+
const error = replies.find(reply => reply.ok === false)?.error; if (error) state.notice = String(error);
|
|
115
|
+
draw();
|
|
116
|
+
} catch (error) { if (active) { state.notice = String((error as Error).message); draw(); } }
|
|
117
|
+
finally { polling = false; }
|
|
118
|
+
};
|
|
119
|
+
const run = options.runCommand ?? ((args, output, finished) => {
|
|
120
|
+
const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
|
|
121
|
+
cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
122
|
+
});
|
|
123
|
+
const read = async (source: ReadableStream<Uint8Array>) => {
|
|
124
|
+
const decoder = new TextDecoder(); const reader = source.getReader();
|
|
125
|
+
try { while (true) { const { value, done } = await reader.read(); if (done) break; output(decoder.decode(value, { stream: true })); } const end = decoder.decode(); if (end) output(end); }
|
|
126
|
+
finally { reader.releaseLock(); }
|
|
127
|
+
};
|
|
128
|
+
void Promise.all([read(child.stdout), read(child.stderr), child.exited]).then(([, , code]) => { if (code) output(`command exited ${code}`); }).catch(error => output(String(error.message))).finally(finished);
|
|
129
|
+
return () => { child.kill(); };
|
|
130
|
+
});
|
|
131
|
+
const effect = async (action: ConsoleEffect) => {
|
|
132
|
+
if (action.type === "exit") return stop();
|
|
133
|
+
if (action.type === "permit") { client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) }); return; }
|
|
134
|
+
if (action.type === "show") {
|
|
135
|
+
const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
|
|
136
|
+
if (!active) return;
|
|
137
|
+
state.detailOffset = 0;
|
|
138
|
+
state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? String(result.text) : JSON.stringify(result.delivery, null, 2);
|
|
139
|
+
draw(); return;
|
|
140
|
+
}
|
|
141
|
+
if (childRunning) { state.notice = "a command is already running"; draw(); return; }
|
|
142
|
+
childRunning = true;
|
|
143
|
+
stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
|
|
144
|
+
try {
|
|
145
|
+
cancelChild = run(action.args, text => { if (active) { stream({ text, kind: "command" }); draw(); } }, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
|
|
146
|
+
} catch (error) { childRunning = false; state.notice = String((error as Error).message); draw(); }
|
|
147
|
+
};
|
|
148
|
+
const handle = (key: string) => {
|
|
149
|
+
if (!active) return;
|
|
150
|
+
const result = reduceConsole(state, key); state = result.state;
|
|
151
|
+
if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; state.notice = "panels need at least 80x24"; }
|
|
152
|
+
for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
|
|
153
|
+
draw();
|
|
154
|
+
if (key === "v" && state.detail && state.mode === "stream") { stream({ text: state.detail, kind: "permission" }); state.detail = undefined; draw(); }
|
|
155
|
+
if (key === "\t") void refresh();
|
|
156
|
+
};
|
|
157
|
+
client.onPush = msg => {
|
|
158
|
+
if (!active) return;
|
|
159
|
+
try {
|
|
160
|
+
if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t });
|
|
161
|
+
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading)}`, peer: msg.peer, kind: "context" });
|
|
162
|
+
else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
|
|
163
|
+
else if (msg.t === "permission") {
|
|
164
|
+
stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission" });
|
|
165
|
+
if (typeof msg.expiresAt === "number" && msg.expiresAt > Date.now() && !state.approvals.some(a => a.id === msg.id)) state.approvals.push(msg);
|
|
166
|
+
} else if (msg.t === "permission_closed") {
|
|
167
|
+
state.approvals = state.approvals.filter(a => a.id !== msg.id); state = pruneApprovals(state, Date.now());
|
|
168
|
+
}
|
|
169
|
+
draw();
|
|
170
|
+
} catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
|
|
171
|
+
};
|
|
172
|
+
client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
|
|
173
|
+
try {
|
|
174
|
+
if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
|
|
175
|
+
if (terminal.isTTY) {
|
|
176
|
+
terminal.raw(true);
|
|
177
|
+
removers.push(terminal.onData(text => {
|
|
178
|
+
// A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
|
|
179
|
+
if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
|
|
180
|
+
state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
|
|
181
|
+
state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
|
|
182
|
+
} else handle(text);
|
|
183
|
+
}));
|
|
184
|
+
removers.push(terminal.onResize(() => {
|
|
185
|
+
columns = terminal.columns; rows = terminal.rows; plain = columns < 80 || rows < 24;
|
|
186
|
+
if (plain) {
|
|
187
|
+
if (inAlternate) { terminal.write("\x1b[?1049l"); inAlternate = false; }
|
|
188
|
+
terminal.write("\x1b[r"); state.mode = "stream"; state.notice = "panels need at least 80x24";
|
|
189
|
+
safeWrite("\nTerminal below 80x24; showing the plain stream.\n");
|
|
190
|
+
}
|
|
191
|
+
draw();
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
removers.push(terminal.onSignal(() => stop()));
|
|
195
|
+
if (terminal.onError) removers.push(terminal.onError(error => { process.exitCode = 1; stop(`Console error: ${error.message}`); }));
|
|
196
|
+
timers.push(setInterval(() => { try { state = pruneApprovals(state, Date.now()); draw(); } catch (error) { stop(`Console error: ${String((error as Error).message)}`); } }, 1000));
|
|
197
|
+
timers.push(setInterval(() => void refresh(), Math.max(2000, options.pollMs ?? 4000)));
|
|
198
|
+
draw(); client.send({ t: "tail" }); void refresh(); await done;
|
|
199
|
+
} catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
|
|
200
|
+
finally { stop(); }
|
|
201
|
+
}
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
// call; the hub confirms it from the transcript row Claude Code writes for it, by tool use id, before it counts as seen.
|
|
6
6
|
import { ControlClient } from "../hub/control-client.ts";
|
|
7
7
|
|
|
8
|
+
/** A library call may target another hub; only the managed command hook inherits that target's native identity. */
|
|
9
|
+
export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string } {
|
|
10
|
+
if (env.AGENTHUB_STATE_DIR !== stateDir || env.AGENTHUB_PEER_ID !== peer) return {};
|
|
11
|
+
return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}) };
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/** The hook's stdout for one Claude Code hook input, or undefined for none. */
|
|
9
15
|
export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
|
|
10
16
|
const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
|
|
11
|
-
const phase = ({ PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
17
|
+
const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
|
|
12
18
|
const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
|
|
13
19
|
try {
|
|
14
20
|
const res = await hub.request({
|
|
15
21
|
t: "facts",
|
|
16
22
|
phase,
|
|
23
|
+
...nativeHookIdentity(stateDir, peer),
|
|
17
24
|
tool: typeof input.tool_name === "string" ? input.tool_name : "",
|
|
18
25
|
input: input.tool_input ?? {},
|
|
19
26
|
...(typeof input.tool_use_id === "string" ? { toolUseId: input.tool_use_id } : {}),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cliCommandLabel } from "./identity.ts";
|
|
4
|
+
|
|
5
|
+
export interface CliAudit {
|
|
6
|
+
v: 1; id: string; at: number; peer: string; command: string; outcome: "run" | "refused" | "invalid";
|
|
7
|
+
}
|
|
8
|
+
const MAX_FILES = 256;
|
|
9
|
+
const MAX_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_ROW_BYTES = 512;
|
|
11
|
+
const READY = /^[a-f0-9-]{36}\.json$/;
|
|
12
|
+
const PENDING = /^[a-f0-9-]{36}\.tmp$/;
|
|
13
|
+
const valid = (row: any): row is CliAudit => row?.v === 1 && typeof row.id === "string" && /^[a-f0-9-]{36}$/.test(row.id)
|
|
14
|
+
&& typeof row.at === "number" && Number.isFinite(row.at) && row.at > 0
|
|
15
|
+
&& typeof row.peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(row.peer) && !/\s/.test(row.peer)
|
|
16
|
+
&& typeof row.command === "string" && row.command.length <= 64 && row.command === cliCommandLabel(...splitLabel(row.command))
|
|
17
|
+
&& ["run", "refused", "invalid"].includes(row.outcome);
|
|
18
|
+
const splitLabel = (label: string): [string, string[]] => { const [command = "unknown", ...args] = label.split(" "); return [command, args]; };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An ids-only outbox, published atomically without a writer lock. Concurrent writers can overshoot
|
|
22
|
+
* the checked 256-file / 64 KiB ceiling by at most one bounded row per concurrent writer.
|
|
23
|
+
* Unpublished temp files count against the ceiling, so a crashed writer cannot grow the spool forever.
|
|
24
|
+
*/
|
|
25
|
+
export function recordCliAudit(stateDir: string, peer: string, command: string, outcome: CliAudit["outcome"]): void {
|
|
26
|
+
const row: CliAudit = { v: 1, id: crypto.randomUUID(), at: Date.now(), peer, command, outcome };
|
|
27
|
+
if (!valid(row)) throw new Error("invalid CLI audit identifiers");
|
|
28
|
+
const dir = join(stateDir, "cli-audit");
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
const files = readdirSync(dir).filter(name => READY.test(name) || PENDING.test(name));
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const name of files) {
|
|
33
|
+
try { bytes += statSync(join(dir, name)).size; }
|
|
34
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
35
|
+
}
|
|
36
|
+
const text = JSON.stringify(row) + "\n";
|
|
37
|
+
if (Buffer.byteLength(text) > MAX_ROW_BYTES || files.length >= MAX_FILES || bytes + Buffer.byteLength(text) > MAX_BYTES) throw new Error("CLI audit outbox is full");
|
|
38
|
+
const pending = join(dir, `${row.id}.tmp`);
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(pending, text, { mode: 0o600, flag: "wx" });
|
|
41
|
+
renameSync(pending, join(dir, `${row.id}.json`));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
try { unlinkSync(pending); } catch { /* only this writer's unpublished file */ }
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Consume ready files only; rebuild fixed fields so titles or other injected text cannot be rendered. */
|
|
49
|
+
export function drainCliAudits(stateDir: string): CliAudit[] {
|
|
50
|
+
const dir = join(stateDir, "cli-audit");
|
|
51
|
+
let files: string[];
|
|
52
|
+
try { files = readdirSync(dir).filter(name => READY.test(name)).sort(); }
|
|
53
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
|
|
54
|
+
const rows: CliAudit[] = [];
|
|
55
|
+
for (const name of files) {
|
|
56
|
+
const path = join(dir, name);
|
|
57
|
+
try {
|
|
58
|
+
if (statSync(path).size <= MAX_ROW_BYTES) {
|
|
59
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
if (valid(parsed) && `${parsed.id}.json` === name) rows.push({ v: 1, id: parsed.id, at: parsed.at, peer: parsed.peer, command: parsed.command, outcome: parsed.outcome });
|
|
61
|
+
}
|
|
62
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
|
|
63
|
+
try { unlinkSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
64
|
+
}
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type CliIdentity =
|
|
2
|
+
| { role: "console" }
|
|
3
|
+
| { role: "tools"; peer: string }
|
|
4
|
+
| { role: "invalid"; reason: string };
|
|
5
|
+
|
|
6
|
+
/** Honest defaults for native shells, not authentication against an agent that can edit its environment. */
|
|
7
|
+
export function detectCliIdentity(env: Readonly<Record<string, string | undefined>>): CliIdentity {
|
|
8
|
+
const hub = env.AGENTHUB_PEER_ID;
|
|
9
|
+
const claude = env.CLAUDECODE;
|
|
10
|
+
const session = env.CLAUDE_CODE_SESSION_ID;
|
|
11
|
+
const codex = env.CODEX_THREAD_ID;
|
|
12
|
+
const invalid = (reason: string): CliIdentity => ({ role: "invalid", reason });
|
|
13
|
+
if (hub !== undefined && (!/^[a-z][a-z0-9-]{0,31}$/.test(hub) || /\s/.test(hub) || ["user", "hub", "digest"].includes(hub))) return invalid("invalid AGENTHUB_PEER_ID marker");
|
|
14
|
+
if (claude !== undefined && claude !== "1") return invalid("invalid CLAUDECODE marker");
|
|
15
|
+
if (session !== undefined && (!session || /\s/.test(session))) return invalid("invalid CLAUDE_CODE_SESSION_ID marker");
|
|
16
|
+
if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
|
|
17
|
+
if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
|
|
18
|
+
if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
|
|
19
|
+
const native = claude !== undefined ? "claude" : codex !== undefined ? "codex" : undefined;
|
|
20
|
+
if (hub !== undefined && native !== undefined && hub !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
|
|
21
|
+
const peer = hub ?? native;
|
|
22
|
+
return peer === undefined ? { role: "console" } : { role: "tools", peer };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
26
|
+
|
|
27
|
+
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
28
|
+
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
29
|
+
if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
|
|
30
|
+
if (command === "task") {
|
|
31
|
+
if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
|
|
32
|
+
if (["assign", "escalate"].includes(args[0] ?? "")) return "conductor";
|
|
33
|
+
return "console";
|
|
34
|
+
}
|
|
35
|
+
if (command === "route") return args[0] === "explain" ? "allowed" : "console";
|
|
36
|
+
if (command === "budget") return !args.length || args.every((arg) => arg === "--json") ? "allowed" : "console";
|
|
37
|
+
if (["claude", "codex", "kimi", "pi", "local", "pause", "resume"].includes(command)) return "conductor";
|
|
38
|
+
if (["help", "--help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
|
|
39
|
+
return "console";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** No argument text is included: notices and refusal logs must not copy task text or credentials. */
|
|
43
|
+
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", "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", "setup", "start", "stop", "remove"].includes(sub)) return `${command} ${sub}`;
|
|
48
|
+
}
|
|
49
|
+
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function peerCommandRefusal(peer: string, command: string): string {
|
|
53
|
+
return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal`;
|
|
54
|
+
}
|
package/src/cli/init.ts
CHANGED
|
@@ -23,47 +23,57 @@ export function removeBlock(existing: string): string {
|
|
|
23
23
|
return rest && `${rest}\n`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
export interface InitChange {
|
|
27
|
+
action: "create" | "modify" | "delete";
|
|
28
|
+
path: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
managedBlock?: "insert" | "replace" | "remove";
|
|
31
|
+
}
|
|
32
|
+
/** Content is internal to apply, never included in a public preview. */
|
|
33
|
+
interface PlannedWrite extends InitChange { next?: string; }
|
|
34
|
+
function changes(cwd: string): PlannedWrite[] {
|
|
35
|
+
const changed: PlannedWrite[] = [];
|
|
36
|
+
const write = (path: string, next: string, reason: string, managedBlock?: InitChange["managedBlock"]) => {
|
|
37
|
+
const exists = existsSync(path);
|
|
38
|
+
if (exists && readFileSync(path, "utf8") === next) return;
|
|
39
|
+
changed.push({ action: exists ? "modify" : "create", path, next, reason, ...(managedBlock ? { managedBlock } : {}) });
|
|
33
40
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const routing = join(cwd, ".agenthub", "routing.toml");
|
|
40
|
-
if (!existsSync(routing)) write(routing, readFileSync(join(TEMPLATES, "routing.toml"), "utf8"));
|
|
41
|
-
|
|
41
|
+
for (const name of ["config.json", "routing.toml"]) {
|
|
42
|
+
const path = join(cwd, ".agenthub", name);
|
|
43
|
+
if (!existsSync(path)) write(path, readFileSync(join(TEMPLATES, name), "utf8"), "create missing project defaults");
|
|
44
|
+
}
|
|
42
45
|
const agents = join(cwd, "AGENTS.md");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Any CLAUDE.md, even an empty one, stops Claude Code from loading AGENTS.md: take back the block older versions wrote there.
|
|
46
|
-
// Only a regular file of its own: a symlink may lead out of the project, and a link to AGENTS.md
|
|
47
|
-
// (hard, or AGENTS.md -> CLAUDE.md) holds the block just written.
|
|
46
|
+
const existing = existsSync(agents) ? readFileSync(agents, "utf8") : "";
|
|
47
|
+
write(agents, upsertBlock(existing, readFileSync(join(TEMPLATES, "AGENTS.block.md"), "utf8")), "refresh hub instructions; preserve surrounding text", existing.includes("<!-- AGENT_HUB:BEGIN") ? "replace" : "insert");
|
|
48
48
|
const claude = join(cwd, "CLAUDE.md");
|
|
49
49
|
const own = lstatSync(claude, { throwIfNoEntry: false });
|
|
50
|
-
|
|
51
|
-
const
|
|
50
|
+
// The write preserves an existing inode, including AGENTS.md -> CLAUDE.md and hard links.
|
|
51
|
+
const target = existsSync(agents) ? statSync(agents) : undefined;
|
|
52
|
+
const legacy = own?.isFile() && !(target && own.dev === target.dev && own.ino === target.ino) ? readFileSync(claude, "utf8") : "";
|
|
52
53
|
const rest = removeBlock(legacy);
|
|
53
54
|
if (rest !== legacy) {
|
|
54
|
-
if (rest) write(claude, rest);
|
|
55
|
-
else {
|
|
56
|
-
unlinkSync(claude);
|
|
57
|
-
changed.push(claude);
|
|
58
|
-
}
|
|
55
|
+
if (rest) write(claude, rest, "remove legacy hub instructions; preserve surrounding text", "remove");
|
|
56
|
+
else changed.push({ action: "delete", path: claude, reason: "remove legacy-only instructions", managedBlock: "remove" });
|
|
59
57
|
}
|
|
60
|
-
|
|
61
58
|
const ignore = join(cwd, ".gitignore");
|
|
62
59
|
let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
|
|
63
|
-
// The machine's own config: only a file nobody committed may choose commands and credential files (issue #17).
|
|
64
60
|
for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
|
|
65
61
|
if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
66
62
|
}
|
|
67
|
-
write(ignore, lines);
|
|
63
|
+
write(ignore, lines, "ignore runtime state and machine-local configuration");
|
|
68
64
|
return changed;
|
|
69
65
|
}
|
|
66
|
+
/** Read-only metadata, deliberately omitting project/user file contents. */
|
|
67
|
+
export function planInit(cwd: string): InitChange[] {
|
|
68
|
+
return changes(cwd).map(({ next, ...change }) => change);
|
|
69
|
+
}
|
|
70
|
+
/** Returns the paths it changed, applying the same plan as the preview. */
|
|
71
|
+
export function init(cwd: string): string[] {
|
|
72
|
+
const plan = changes(cwd);
|
|
73
|
+
mkdirSync(join(cwd, ".agenthub"), { recursive: true });
|
|
74
|
+
for (const change of plan) {
|
|
75
|
+
if (change.action === "delete") unlinkSync(change.path);
|
|
76
|
+
else writeFileSync(change.path, change.next!);
|
|
77
|
+
}
|
|
78
|
+
return plan.map(change => change.path);
|
|
79
|
+
}
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { peerChildEnv } from "../hub/child-process.ts";
|
|
4
5
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
5
6
|
|
|
7
|
+
/** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
|
|
8
|
+
export function nativeLaunchEnv(tool: "claude" | "codex" | "pi", source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
9
|
+
const env = peerChildEnv(tool, source);
|
|
10
|
+
delete env.AGENTHUB_UNATTENDED;
|
|
11
|
+
return env;
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
/** A scoped candidate MCP server can be selected without promoting the installed plugin. */
|
|
7
15
|
function claudeChannel(userArgs: string[]): string {
|
|
8
16
|
const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
|
|
@@ -55,6 +63,18 @@ export function statusLineSettings(tee: StatusLineTee): string {
|
|
|
55
63
|
export interface FactsHook {
|
|
56
64
|
script: string;
|
|
57
65
|
stateDir: string;
|
|
66
|
+
/** The same hook transport can observe native turns without injecting facts. */
|
|
67
|
+
purpose?: "facts" | "idle" | "facts-and-idle";
|
|
68
|
+
/** Observe native sessions/turns for every enabled facts, idle or conductor hook configuration. */
|
|
69
|
+
observeNative?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
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 {
|
|
74
|
+
const facts = config.coordination === "turn-free";
|
|
75
|
+
const idle = config.task_sweep?.enabled === true;
|
|
76
|
+
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;
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
/**
|
|
@@ -67,6 +87,7 @@ export function sessionSettings(tee: StatusLineTee, facts?: FactsHook): string {
|
|
|
67
87
|
if (facts) {
|
|
68
88
|
const hooks = [{ type: "command", command: `AGENTHUB_STATE_DIR=${sh(facts.stateDir)} bun ${sh(facts.script)}`, timeout: 5 }];
|
|
69
89
|
settings.hooks = { PreToolUse: [{ matcher: "*", hooks }], PostToolUse: [{ matcher: "*", hooks }], Stop: [{ hooks }] };
|
|
90
|
+
if (facts.observeNative) Object.assign(settings.hooks as object, { SessionStart: [{ matcher: "*", hooks }], UserPromptSubmit: [{ hooks }] });
|
|
70
91
|
}
|
|
71
92
|
return JSON.stringify(settings);
|
|
72
93
|
}
|
|
@@ -90,7 +111,9 @@ export function buildLaunch(
|
|
|
90
111
|
const notes = [
|
|
91
112
|
unattended ? UNATTENDED_WARNING : "",
|
|
92
113
|
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)." : "",
|
|
93
|
-
ctx.facts && own ? "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." : "",
|
|
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." : "",
|
|
94
117
|
].filter(Boolean);
|
|
95
118
|
return {
|
|
96
119
|
cmd: "claude",
|
|
@@ -105,3 +128,10 @@ export function buildLaunch(
|
|
|
105
128
|
...warning,
|
|
106
129
|
};
|
|
107
130
|
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
/** The daemon and preview use exactly the same ACP command insertion. */
|
|
134
|
+
export function buildKimiLaunch(command: string[], model?: string): Launch {
|
|
135
|
+
const [cmd, ...args] = command;
|
|
136
|
+
return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
|
|
137
|
+
}
|