@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/console.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { StringDecoder } from "node:string_decoder";
|
|
3
4
|
import type { ControlClient } from "../hub/control-client.ts";
|
|
4
5
|
import { contextLine } from "./status-lines.ts";
|
|
5
6
|
import { renderTailEvent } from "./tail-render.ts";
|
|
6
|
-
import { initialConsoleState, paint, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, terminalText, wrap } from "./console-state.ts";
|
|
7
|
-
import type { ConsoleEffect, ConsoleEvent, Tone } from "./console-state.ts";
|
|
7
|
+
import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap } from "./console-state.ts";
|
|
8
|
+
import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
|
|
8
9
|
import type { BusEvent } from "../hub/bus.ts";
|
|
9
10
|
|
|
10
11
|
export interface ConsoleTerminal {
|
|
@@ -56,11 +57,16 @@ function nativeTerminal(): ConsoleTerminal {
|
|
|
56
57
|
onSignal: listener => { process.on("SIGINT", listener); process.on("SIGTERM", listener); return () => { process.off("SIGINT", listener); process.off("SIGTERM", listener); }; },
|
|
57
58
|
};
|
|
58
59
|
}
|
|
60
|
+
/** `task_show` text as labeled fields; a message that is not a task stays text. */
|
|
61
|
+
function labeled(text: string): Detail {
|
|
62
|
+
try { const value = JSON.parse(text); return value && typeof value === "object" && !Array.isArray(value) ? value : String(value); }
|
|
63
|
+
catch { return text; }
|
|
64
|
+
}
|
|
59
65
|
/** One terminal client, no daemon ownership. All text crosses terminalText before writing. */
|
|
60
66
|
export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
61
67
|
const { client } = options; const terminal = options.terminal ?? nativeTerminal();
|
|
62
68
|
const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
63
|
-
let state = initialConsoleState(!!options.panels);
|
|
69
|
+
let state = initialConsoleState(!!options.panels); state.project = basename(options.cwd);
|
|
64
70
|
let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
|
|
65
71
|
let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
|
|
66
72
|
let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
|
|
@@ -68,17 +74,19 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
68
74
|
const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
|
|
69
75
|
let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
|
|
70
76
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
77
|
+
const notice = (text: string) => notify(state, text, Date.now());
|
|
71
78
|
const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
|
|
72
|
-
const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => wrap(text, columns).map(line => [{ text: line
|
|
79
|
+
const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]));
|
|
73
80
|
const writeStream = (event: ConsoleEvent) => {
|
|
74
|
-
terminal.write(`\x1b[${rows -
|
|
81
|
+
terminal.write(`\x1b[${rows - 4};1H`); // the scroll region ends above the rule and the three footer lines
|
|
75
82
|
for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
|
|
76
83
|
};
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
/** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
|
|
85
|
+
const stream = (event: ConsoleEvent, record = true) => {
|
|
86
|
+
if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
|
|
79
87
|
if (plain) {
|
|
80
88
|
const [header, ...body] = terminalText(event.text).split("\n");
|
|
81
|
-
terminal.write(paint([
|
|
89
|
+
terminal.write(paint([...(color ? streamTokens(header ?? "", event.tone, event.kind) : [{ text: header ?? "" }]), ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
|
|
82
90
|
}
|
|
83
91
|
else if (state.mode === "stream") writeStream(event);
|
|
84
92
|
else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
|
|
@@ -87,14 +95,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
87
95
|
if (!active || plain) return;
|
|
88
96
|
if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
|
|
89
97
|
else if (state.mode === "stream" && inAlternate) {
|
|
90
|
-
terminal.write(`\x1b[?1049l\x1b[1;${rows -
|
|
91
|
-
if (droppedStream) writeStream({ text: `${droppedStream
|
|
98
|
+
terminal.write(`\x1b[?1049l\x1b[1;${rows - 4}r`); inAlternate = false;
|
|
99
|
+
if (droppedStream) writeStream({ text: `${plural(droppedStream, "older panel-mode event")} omitted from console memory; inspect hub.log for the full stream.` });
|
|
92
100
|
for (const event of pendingStream) writeStream(event);
|
|
93
101
|
pendingStream = []; droppedStream = 0;
|
|
94
102
|
}
|
|
95
|
-
terminal.write(state.mode === "stream" ? `\x1b[1;${rows -
|
|
103
|
+
terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 4}r` : "\x1b[r");
|
|
96
104
|
const lines = renderConsoleLines(state, columns, rows);
|
|
97
|
-
const start =
|
|
105
|
+
const start = rows - lines.length + 1;
|
|
98
106
|
for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
|
|
99
107
|
terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(paint(lines.at(-1) ?? [], false)) + 1)}H\x1b[?25h`);
|
|
100
108
|
};
|
|
@@ -122,16 +130,19 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
122
130
|
]);
|
|
123
131
|
if (!active) return;
|
|
124
132
|
const [status, budget, tasks, queue, ready] = replies;
|
|
133
|
+
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
125
134
|
if (status.status?.peers) state.peers = status.status.peers;
|
|
135
|
+
if (syncPermissionDefaults(state, status.status?.permissionDefaults) && state.confirm?.type === "permission_default") stream({ text: permissionDefaultText(state.confirm), kind: "permission_default", peer: state.confirm.peer, tone: "attention" });
|
|
126
136
|
if (budget.budget) state.budget = budget.budget;
|
|
127
|
-
if (tasks?.ok
|
|
137
|
+
if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
|
|
128
138
|
const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
|
|
129
139
|
state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
|
|
140
|
+
state.tasksKnown = true;
|
|
130
141
|
} }
|
|
131
142
|
if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
|
|
132
|
-
const error = replies.find(reply => reply.ok === false)?.error; if (error)
|
|
143
|
+
const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
|
|
133
144
|
draw();
|
|
134
|
-
} catch (error) { if (active) {
|
|
145
|
+
} catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
|
|
135
146
|
finally { polling = false; }
|
|
136
147
|
};
|
|
137
148
|
const run = options.runCommand ?? ((args, output, finished) => {
|
|
@@ -148,43 +159,62 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
148
159
|
});
|
|
149
160
|
const effect = async (action: ConsoleEffect) => {
|
|
150
161
|
if (action.type === "exit") return stop();
|
|
162
|
+
if (action.type === "permission_default") {
|
|
163
|
+
const result = await client.request({ t: "permission_default", peer: action.peer, confirmed: action.confirmed }, 35_000);
|
|
164
|
+
if (!active) return;
|
|
165
|
+
if (result.ok === false) {
|
|
166
|
+
notice(String(result.error ?? "permission default refused"));
|
|
167
|
+
} else stream({ text: ` ${action.peer} never-ask default ${action.confirmed ? "enabled" : "cancelled for this hub"}`, kind: "permission_default", peer: action.peer, tone: "attention" });
|
|
168
|
+
await refresh(); draw(); return;
|
|
169
|
+
}
|
|
151
170
|
if (action.type === "permit") {
|
|
152
171
|
client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
|
|
153
172
|
if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
|
|
154
173
|
return;
|
|
155
174
|
}
|
|
175
|
+
if (action.type === "keys") return stream({ text: keyTable(columns).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
|
|
176
|
+
if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
|
|
156
177
|
if (action.type === "show") {
|
|
157
178
|
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);
|
|
158
|
-
|
|
179
|
+
// A late reply is for the panel and row it was asked for: once the person has moved on, it would show one item
|
|
180
|
+
// while the keys act on another, so it is dropped.
|
|
181
|
+
if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(panelRows(state)[state.selection]?.id) !== action.id) return;
|
|
159
182
|
state.detailOffset = 0;
|
|
160
|
-
state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? String(result.text) :
|
|
183
|
+
state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? labeled(String(result.text)) : result.delivery ?? "delivery not found";
|
|
161
184
|
draw(); return;
|
|
162
185
|
}
|
|
163
|
-
if (childRunning) {
|
|
186
|
+
if (childRunning) { notice("a command is already running"); draw(); return; }
|
|
164
187
|
childRunning = true;
|
|
165
188
|
stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
|
|
166
189
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
190
|
+
// Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
|
|
191
|
+
const output = (text: string) => { if (active) { stream({ text: text.replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
|
|
192
|
+
cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
|
|
193
|
+
} catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
|
|
169
194
|
};
|
|
170
195
|
const handle = (key: string) => {
|
|
171
196
|
if (!active) return;
|
|
172
197
|
const result = reduceConsole(state, key); state = result.state;
|
|
173
|
-
if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream";
|
|
198
|
+
if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
|
|
174
199
|
for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
|
|
175
200
|
draw();
|
|
176
|
-
if (key === "v" && state.detail && state.mode === "stream") { stream({ text: state.detail, kind: "permission" }); state.detail = undefined; draw(); }
|
|
177
201
|
if (key === "\t") void refresh();
|
|
178
202
|
};
|
|
179
203
|
client.onPush = msg => {
|
|
180
204
|
if (!active) return;
|
|
181
205
|
try {
|
|
182
206
|
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, tone: eventTone(msg.e) });
|
|
183
|
-
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading)}`, peer: msg.peer, kind: "context" });
|
|
207
|
+
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading, at => new Date(at).toLocaleTimeString())}`, peer: msg.peer, kind: "context" });
|
|
184
208
|
else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
|
|
185
209
|
else if (msg.t === "permission") {
|
|
186
|
-
|
|
187
|
-
|
|
210
|
+
const request = approvalFrom(msg);
|
|
211
|
+
stream({ text: permissionText(request), peer: request.peer, kind: "permission", tone: "attention" });
|
|
212
|
+
// Only the first request is selected on arrival: once a selection has ended, a request that arrives as the person
|
|
213
|
+
// reacts must not take its place, so from then on only [ ], j or k select.
|
|
214
|
+
if (request.expiresAt > Date.now() && !state.approvals.some(a => a.id === request.id)) {
|
|
215
|
+
state.approvals.push(request);
|
|
216
|
+
if (!state.autoSelected) { state.approvalId = request.id; state.autoSelected = true; }
|
|
217
|
+
}
|
|
188
218
|
} else if (msg.t === "permission_closed") {
|
|
189
219
|
state.approvals = state.approvals.filter(a => a.id !== msg.id); state = pruneApprovals(state, Date.now());
|
|
190
220
|
if (msg.reason === "expired" || msg.outcome === "cancelled" || msg.outcome?.startsWith("reject")) stream({ text: ` ! ${msg.peer} permission ${msg.id} ${msg.reason ?? msg.outcome}`, peer: msg.peer, kind: "permission", tone: "failure" });
|
|
@@ -199,7 +229,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
199
229
|
terminal.raw(true);
|
|
200
230
|
removers.push(terminal.onData(text => {
|
|
201
231
|
// A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
|
|
232
|
+
// Under the modal key table it does nothing at all.
|
|
202
233
|
if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
|
|
234
|
+
if (state.help && state.mode === "panels") return;
|
|
235
|
+
if (state.confirm?.type === "permission_default") { draw(); return; }
|
|
203
236
|
state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
|
|
204
237
|
state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
|
|
205
238
|
} else handle(text);
|
|
@@ -208,7 +241,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
208
241
|
columns = terminal.columns; rows = terminal.rows; plain = columns < 80 || rows < 24;
|
|
209
242
|
if (plain) {
|
|
210
243
|
if (inAlternate) { terminal.write("\x1b[?1049l"); inAlternate = false; }
|
|
211
|
-
terminal.write("\x1b[r"); state.mode = "stream";
|
|
244
|
+
terminal.write("\x1b[r"); state.mode = "stream"; notice("panels need at least 80x24");
|
|
212
245
|
safeWrite("\nTerminal below 80x24; showing the plain stream.\n");
|
|
213
246
|
}
|
|
214
247
|
draw();
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// Claude
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
|
|
3
|
+
// PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
|
|
4
|
+
// A failed or timed-out hub decides nothing, leaving Claude's native permission rules in effect.
|
|
5
|
+
import { relative, resolve, isAbsolute } from "node:path";
|
|
6
|
+
import { realPath } from "../hub/project.ts";
|
|
7
|
+
import { isAgentConfigPath, isPermissionMode, hardLinked, isHubWritePath } from "../hub/permission-mode.ts";
|
|
6
8
|
import { ControlClient } from "../hub/control-client.ts";
|
|
7
9
|
|
|
8
10
|
/** 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 } {
|
|
11
|
+
export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string; hookPurpose?: string } {
|
|
10
12
|
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 } : {}) };
|
|
13
|
+
return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}), ...(["facts", "idle", "facts-and-idle", "permission"].includes(env.AGENTHUB_HOOK_PURPOSE ?? "") ? { hookPurpose: env.AGENTHUB_HOOK_PURPOSE } : {}) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only resolving project paths outside the hub/native configuration directories receive a file grant. */
|
|
17
|
+
export function projectFileTool(tool: unknown, input: unknown, projectRoot: unknown): boolean {
|
|
18
|
+
if (typeof projectRoot !== "string" || !isAbsolute(projectRoot) || !input || typeof input !== "object" || Array.isArray(input)) return false;
|
|
19
|
+
const args = input as Record<string, unknown>;
|
|
20
|
+
const key = ["Read", "Edit", "Write", "MultiEdit"].includes(String(tool)) ? "file_path" : tool === "NotebookEdit" ? "notebook_path" : ["LS", "Glob", "Grep"].includes(String(tool)) ? "path" : undefined;
|
|
21
|
+
if (!key) return false;
|
|
22
|
+
const optional = tool === "Glob" || tool === "Grep";
|
|
23
|
+
if (args[key] !== undefined && (typeof args[key] !== "string" || !args[key])) return false;
|
|
24
|
+
if (!optional && args[key] === undefined) return false;
|
|
25
|
+
try {
|
|
26
|
+
const root = realPath(projectRoot);
|
|
27
|
+
const protectedPath = (path: string) => isAgentConfigPath(path) || isHubWritePath(path);
|
|
28
|
+
const safe = (path: string) => {
|
|
29
|
+
const requested = resolve(root, path), lexical = relative(root, requested);
|
|
30
|
+
if (protectedPath(lexical)) return false;
|
|
31
|
+
const real = realPath(requested), canonical = relative(root, real);
|
|
32
|
+
// A hard link is another name for a file that may sit in a protected directory.
|
|
33
|
+
return canonical !== ".." && !canonical.startsWith("../") && !isAbsolute(canonical) && !protectedPath(canonical) && !hardLinked(real);
|
|
34
|
+
};
|
|
35
|
+
const target = typeof args[key] === "string" ? args[key] as string : root;
|
|
36
|
+
if (target.split(/[\\/]/).includes("..") || !safe(target)) return false;
|
|
37
|
+
if (tool === "Grep" && args.glob !== undefined) {
|
|
38
|
+
if (typeof args.glob !== "string" || !args.glob || args.glob.split(/[\\/]/).includes("..") || protectedPath(args.glob)) return false;
|
|
39
|
+
if (/[?*[{]/.test(args.glob) || !safe(resolve(root, target, args.glob))) return false;
|
|
40
|
+
}
|
|
41
|
+
if (tool === "Glob") {
|
|
42
|
+
if (typeof args.pattern !== "string" || !args.pattern) return false;
|
|
43
|
+
if (args.pattern.split(/[\\/]/).includes("..") || protectedPath(args.pattern)) return false;
|
|
44
|
+
// Wildcard expansions can enter symlinked or protected directories; their exact targets are unknown here.
|
|
45
|
+
if (/[?*[{]/.test(args.pattern) || !safe(resolve(root, target, args.pattern))) return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
} catch { return false; }
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
/** The hook's stdout for one Claude Code hook input, or undefined for none. */
|
|
@@ -28,8 +65,13 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
28
65
|
...(typeof input.transcript_path === "string" ? { transcriptPath: input.transcript_path } : {}),
|
|
29
66
|
startedMs: performance.now(), // this process's own start-up and connect time, for the latency record
|
|
30
67
|
}, timeoutMs);
|
|
31
|
-
if (phase !== "pre" || !res?.ok
|
|
32
|
-
|
|
68
|
+
if (phase !== "pre" || !res?.ok) return undefined;
|
|
69
|
+
const permission = isPermissionMode(res.permission) ? res.permission : "ask";
|
|
70
|
+
const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot);
|
|
71
|
+
const allow = input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
|
|
72
|
+
const text = typeof res.text === "string" && res.text ? res.text : undefined;
|
|
73
|
+
if (!allow && !text) return undefined;
|
|
74
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(allow ? { permissionDecision: "allow" } : {}) } });
|
|
33
75
|
} finally {
|
|
34
76
|
hub.close();
|
|
35
77
|
}
|
package/src/cli/help.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { VERSION } from "../version.ts";
|
|
2
|
+
import { paint, wrap, type Span } from "./console-state.ts";
|
|
3
|
+
|
|
4
|
+
/** One usage form of `ahub help`: the usage starts with `ahub <command>`, alternatives joined by `|`. */
|
|
5
|
+
export interface HelpEntry { section: string; usage: string; description: string }
|
|
6
|
+
|
|
7
|
+
const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
8
|
+
["Projects", [
|
|
9
|
+
["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
|
|
10
|
+
["ahub projects [--json]", "list registered projects and live status"],
|
|
11
|
+
["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
|
|
12
|
+
["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
|
|
13
|
+
["ahub init [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
|
|
14
|
+
["ahub help [command]", "this help, or one command's entries; also --help and -h"],
|
|
15
|
+
["ahub version | --version", "print the installed version"],
|
|
16
|
+
]],
|
|
17
|
+
["Daemon and runtime", [
|
|
18
|
+
["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
|
|
19
|
+
["ahub console [--panels] [--color=auto|always|never]", "enter the human console, leaving the daemon running on exit"],
|
|
20
|
+
["ahub status", "the hub, its peers, model backends and task counts"],
|
|
21
|
+
["ahub status --all", "show every registered project"],
|
|
22
|
+
["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
|
|
23
|
+
["ahub doctor", "check the tools, daemon, plugin, gateway, models and memory worker this project uses"],
|
|
24
|
+
["ahub doctor --orphans [--kill]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
|
|
25
|
+
["ahub kill", "stop this project's hub"],
|
|
26
|
+
["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
|
|
27
|
+
["ahub restart [--dry-run] [--yes]", "recover this project's runtime"],
|
|
28
|
+
["ahub upgrade --to <version> [--dry-run] [--yes]", "review and upgrade running projects"],
|
|
29
|
+
["ahub recovery status|resume|abort <operation-id>", "inspect, resume or cancel a preflight; status names the next actions"],
|
|
30
|
+
["ahub recovery dispose <operation-id> --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
31
|
+
["ahub recovery dispose <operation-id> --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
32
|
+
["ahub daemon [--unattended]", "internal: the detached hub process that ahub up or the dashboard manager starts"],
|
|
33
|
+
["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
|
|
34
|
+
]],
|
|
35
|
+
["Agents", [
|
|
36
|
+
["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
|
|
37
|
+
["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
|
|
38
|
+
["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
|
|
39
|
+
["ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi"],
|
|
40
|
+
["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
|
|
41
|
+
["ahub models setup|status|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
|
|
42
|
+
]],
|
|
43
|
+
["Messages and approvals", [
|
|
44
|
+
["ahub say [@peer ...] <text>", "send as the console user (no @peer = broadcast); delivered at once, start the text with [STATUS] to let it batch or [FYI] for the record only"],
|
|
45
|
+
["ahub tail", "live stream of messages, states and permission requests"],
|
|
46
|
+
["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
|
|
47
|
+
["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
|
|
48
|
+
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
49
|
+
["ahub queue list [--peer <id>] [--json]", "inspect durable deliveries"],
|
|
50
|
+
["ahub queue show <delivery-id>", "inspect one delivery and revision"],
|
|
51
|
+
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
|
|
52
|
+
]],
|
|
53
|
+
["Tasks and review", [
|
|
54
|
+
["ahub board [state | --ready]", "the task board; --ready: proposed tasks with nothing left to wait for"],
|
|
55
|
+
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
56
|
+
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
57
|
+
["ahub task assign <id> <peer>", "give a task to a peer yourself; a task that still waits is reserved for that peer instead"],
|
|
58
|
+
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
59
|
+
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
60
|
+
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
61
|
+
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
62
|
+
["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
|
|
63
|
+
["ahub remember <text...>", "save a note to the memory all agents share"],
|
|
64
|
+
]],
|
|
65
|
+
["Budget", [
|
|
66
|
+
["ahub budget", "quota windows per peer, and who is paused until when"],
|
|
67
|
+
["ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]", "feed a reading by hand (also: test the relay)"],
|
|
68
|
+
["ahub budget resume <peer>", "override a budget pause; readings are ignored for that peer until the window resets"],
|
|
69
|
+
["ahub budget execution configure <config.json> | status [id] | disable <id>", "opt-in limits on model calls, tool calls, time and tokens for pi and local"],
|
|
70
|
+
]],
|
|
71
|
+
["History and reports", [
|
|
72
|
+
["ahub turns [peer] [--limit N]", "recent turns and the files each changed (a git work tree only)"],
|
|
73
|
+
["ahub undo <turn> [--yes] [--context]", "put back the files a turn changed; refuses files changed since. Without --yes it only lists them; --context also drops a Codex turn from its conversation"],
|
|
74
|
+
["ahub report [--since 7d|<iso>] [--by task] [--json]", "turns, tokens, messages, overlaps and task events per period"],
|
|
75
|
+
["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
|
|
76
|
+
["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
|
|
77
|
+
["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
|
|
78
|
+
["ahub research backfill", "build this project's research records once from events.jsonl"],
|
|
79
|
+
["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree)"],
|
|
80
|
+
["ahub bench list|status", "benchmark runs on this machine / the one running now"],
|
|
81
|
+
["ahub bench report <run> [--json]", "pass rate, first pass, rework, tokens and wall time per task and overall"],
|
|
82
|
+
["ahub bench compare <run|arm>... [--suite <name>] [--mixed] [--json]", "compare arms of one suite with 95% bootstrap intervals; inconclusive below 5 counted attempts per arm"],
|
|
83
|
+
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
84
|
+
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
85
|
+
]],
|
|
86
|
+
["Hooks", [
|
|
87
|
+
["ahub check-path --hook", "check-path as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks"],
|
|
88
|
+
["ahub facts --hook", "turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks"],
|
|
89
|
+
]],
|
|
90
|
+
["Dashboard", [
|
|
91
|
+
["ahub ui [--no-open]", "open the local dashboard (or print its one-time link)"],
|
|
92
|
+
["ahub ui --all [--no-open]", "open the unified project dashboard"],
|
|
93
|
+
["ahub ui --all --stop", "stop only the dashboard manager"],
|
|
94
|
+
["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
|
|
95
|
+
]],
|
|
96
|
+
];
|
|
97
|
+
export const HELP: readonly HelpEntry[] = SECTIONS.flatMap(([section, rows]) => rows.map(([usage, description]) => ({ section, usage, description })));
|
|
98
|
+
|
|
99
|
+
const COMMAND = /^ahub (-{0,2}[a-z][a-z-]*(?: ?\| ?-{0,2}[a-z][a-z-]*)*)/;
|
|
100
|
+
/** The command words a usage documents: `ahub pause|resume <peer>` documents pause and resume. */
|
|
101
|
+
export function helpCommands(entry: HelpEntry): string[] {
|
|
102
|
+
return COMMAND.exec(entry.usage)?.[1]!.split(/ ?\| ?/) ?? [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const DESCRIPTION = 34;
|
|
106
|
+
/**
|
|
107
|
+
* Usage at 2 spaces, description at one fixed column, wrapped at word boundaries to the terminal width
|
|
108
|
+
* (80 to 100 columns). A usage too long for that column puts its description on the next line.
|
|
109
|
+
* With a command, only its entries; an unknown command renders nothing.
|
|
110
|
+
*/
|
|
111
|
+
export function renderHelp(columns: number, color: boolean, command?: string): string {
|
|
112
|
+
const width = Math.min(100, Math.max(80, columns || 80));
|
|
113
|
+
const entries = command === undefined ? HELP : HELP.filter(entry => helpCommands(entry).includes(command));
|
|
114
|
+
const lines: Span[][] = command === undefined ? wrap(`agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory`, width, 0).map(text => [{ text }]) : [];
|
|
115
|
+
let section = "";
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (entry.section !== section) {
|
|
118
|
+
if (lines.length) lines.push([]);
|
|
119
|
+
lines.push([{ text: entry.section, tone: "strong" }]);
|
|
120
|
+
section = entry.section;
|
|
121
|
+
}
|
|
122
|
+
const prefix = COMMAND.exec(entry.usage)?.[0] ?? "";
|
|
123
|
+
const usage = wrap(entry.usage, width - 4, 0);
|
|
124
|
+
const description = wrap(entry.description, width - DESCRIPTION, 0);
|
|
125
|
+
const first: Span[] = [{ text: " " }, { text: prefix, tone: "info" }, { text: usage[0]!.slice(prefix.length) }];
|
|
126
|
+
if (usage.length === 1 && 2 + Bun.stringWidth(usage[0]!) + 2 <= DESCRIPTION) first.push({ text: " ".repeat(DESCRIPTION - 2 - Bun.stringWidth(usage[0]!)) + description.shift() });
|
|
127
|
+
lines.push(first, ...usage.slice(1).map(text => [{ text: ` ${text}` }]), ...description.map(text => [{ text: " ".repeat(DESCRIPTION) + text }]));
|
|
128
|
+
}
|
|
129
|
+
return lines.map(line => paint(line, color)).join("\n");
|
|
130
|
+
}
|
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,18 +34,23 @@ 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
|
-
if (["help", "
|
|
44
|
+
if (["help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
|
|
39
45
|
return "console";
|
|
40
46
|
}
|
|
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", "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}`;
|
|
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
|
}
|