@staix/agent-hub 0.12.19 → 0.12.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -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, ...(index === 0 && event.tone ? { tone: event.tone } : {}) }]));
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 - 3};1H`);
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
- const stream = (event: ConsoleEvent) => {
78
- state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
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([{ text: header ?? "", tone: event.tone }, ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
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 - 3}r`); inAlternate = false;
91
- if (droppedStream) writeStream({ text: `${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.` });
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 - 3}r` : "\x1b[r");
103
+ terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 4}r` : "\x1b[r");
96
104
  const lines = renderConsoleLines(state, columns, rows);
97
- const start = state.mode === "stream" ? rows - 2 : 1;
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 && state.mode === "panels") { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
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) state.notice = String(error);
143
+ const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
133
144
  draw();
134
- } catch (error) { if (active) { state.notice = String((error as Error).message); draw(); } }
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
- if (!active) return;
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) : JSON.stringify(result.delivery, null, 2);
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) { state.notice = "a command is already running"; draw(); return; }
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
- cancelChild = run(action.args, text => { if (active) { stream({ text, kind: "command" }); draw(); } }, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
168
- } catch (error) { childRunning = false; state.notice = String((error as Error).message); draw(); }
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"; state.notice = "panels need at least 80x24"; }
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
- 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", tone: "attention" });
187
- if (typeof msg.expiresAt === "number" && msg.expiresAt > Date.now() && !state.approvals.some(a => a.id === msg.id)) state.approvals.push(msg);
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"; state.notice = "panels need at least 80x24";
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();
@@ -1,14 +1,51 @@
1
1
  #!/usr/bin/env bun
2
- // Claude Code PreToolUse, PostToolUse and Stop hook for turn-free facts (issue #108). `ahub claude` adds it to the
3
- // session's settings in a turn-free project. It never blocks or fails a tool call: a hub that is down, an advisory
4
- // project or any error prints nothing. A fact comes back as `additionalContext`, which Claude Code adds with the tool
5
- // call; the hub confirms it from the transcript row Claude Code writes for it, by tool use id, before it counts as seen.
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 || typeof res.text !== "string" || !res.text) return undefined;
32
- return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: res.text } });
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
  }
@@ -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
+ }
@@ -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", "--help", "version", "--version", "say", "status", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
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
  }