@staix/agent-hub 0.12.20 → 0.12.22

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 (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
@@ -1,9 +1,13 @@
1
+ import { PERMISSION_MODES } from "../hub/permission-mode.ts";
2
+ import { taskProgress } from "../ui/task-progress.ts";
3
+ import type { ProgressStage } from "../ui/task-progress.ts";
1
4
  import { sanitize } from "../hub/envelope.ts";
2
5
 
3
6
  export interface Approval {
4
7
  id: string; peer: string; title: string; expiresAt: number;
5
8
  options: { optionId: string; name: string; kind: string }[];
6
9
  }
10
+ export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
7
11
  export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
8
12
  export interface ConsoleState {
9
13
  mode: "stream" | "panels"; panel: number; selection: number;
@@ -15,18 +19,57 @@ export interface ConsoleState {
15
19
  autoSelected?: boolean;
16
20
  input: string; editing: boolean; history: string[]; historyIndex: number;
17
21
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
18
- tasks: any[]; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
22
+ tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
19
23
  peerFilter?: string; kindFilter?: string; project?: string;
20
- confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
24
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
21
25
  optionChoice?: string;
26
+ modeChoice?: string;
27
+ permissionDefaults: PermissionDefault[];
28
+ permissionDefaultsHandled: string[];
22
29
  }
23
30
  /** A detail view: text as the stream shows it, or labeled fields. */
24
31
  export type Detail = string | Record<string, unknown>;
25
32
  export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
26
- { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
33
+ { type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
27
34
  export function initialConsoleState(panels = false): ConsoleState {
28
35
  return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
29
- input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
36
+ input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
37
+ }
38
+ /** Approval frequency does not create a common sandbox across native peers. */
39
+ export function permissionBoundary(peer: string): string {
40
+ if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
41
+ if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
42
+ return `${peer}: runs its own tools; NO hub sandbox`;
43
+ }
44
+ function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
45
+ if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
46
+ if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
47
+ if (mode === "unknown") return `${peer} permission mode is unknown; inspect or reconnect its native session`;
48
+ return undefined;
49
+ }
50
+ /** Queue sizes and timestamps do not make a refused/default-deferred peer ready. */
51
+ function permissionDefaultFingerprint(s: ConsoleState, row: Pick<PermissionDefault, "peer" | "source">): string {
52
+ const peer = s.peers[row.peer];
53
+ // Readiness, not activity: a peer going idle or busy does not make a deferred default worth asking about again.
54
+ return JSON.stringify([row.peer, row.source, !peer ? "absent" : peer.state === "offline" ? "offline" : "attached", peer?.permissionMode ?? "ask", peer?.attached ?? !!peer, peer?.claiming ?? false, peer?.toolsOnly ?? false]);
55
+ }
56
+ /** Offer one file default only when no other keyboard decision or edit is active. */
57
+ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean {
58
+ if (!Array.isArray(rows)) rows = [];
59
+ s.permissionDefaults = (rows as unknown[]).filter((row): row is PermissionDefault => !!row && typeof row === "object" &&
60
+ typeof (row as PermissionDefault).peer === "string" && (row as PermissionDefault).mode === "never-ask" && typeof (row as PermissionDefault).source === "string");
61
+ const current = new Set(s.permissionDefaults.map(row => permissionDefaultFingerprint(s, row)));
62
+ s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
63
+ const pending = s.confirm;
64
+ if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
65
+ if (s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
66
+ const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
67
+ if (!next) return false;
68
+ s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
69
+ return true;
70
+ }
71
+ export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
72
+ return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
30
73
  }
31
74
  /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
32
75
  export function terminalText(value: unknown): string {
@@ -36,10 +79,12 @@ export function terminalText(value: unknown): string {
36
79
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
37
80
  .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
38
81
  }
39
- export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted";
82
+ export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
40
83
  export interface Span { text: string; tone?: Tone }
41
84
  export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
42
85
  info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
86
+ peerClaude: "\x1b[94m", peerCodex: "\x1b[96m",
87
+ taskKeyword: "\x1b[35m", number: "\x1b[1m", issueRef: "\x1b[4m", taskRef: "\x1b[4;35m",
43
88
  });
44
89
  export function paint(line: Span[], color: boolean): string {
45
90
  return line.map(span => {
@@ -48,6 +93,83 @@ export function paint(line: Span[], color: boolean): string {
48
93
  return color && sgr && text ? sgr + text + "\x1b[0m" : text;
49
94
  }).join("");
50
95
  }
96
+ /** Console stream headers only. Tokenize sanitized text; bodies and command output stay plain. */
97
+ export function streamTokens(value: string, eventTone?: Tone, kind?: string): Span[] {
98
+ const text = terminalText(value);
99
+ if (kind === "command" || kind === "console") return [{ text }];
100
+ const peers: Readonly<Record<string, Tone>> = { claude: "peerClaude", codex: "peerCodex" };
101
+ // Hub-written peer slots only; words such as local/pi/hub in a title do not identify a speaker.
102
+ const peerSlots = new Set<number>();
103
+ const leading = text.match(/^\s*(?:[.?*!]\s+)?([A-Za-z_][A-Za-z_0-9-]*)(?=:| is | asks permission:| permission )/);
104
+ if (leading) peerSlots.add(leading[0].lastIndexOf(leading[1]!));
105
+ const route = text.match(/^\s*(?:[0-9:]+ (?:AM|PM) )?([A-Za-z_][A-Za-z_0-9-]*) -> ([A-Za-z_][A-Za-z_0-9,-]*|\*)/);
106
+ if (route) {
107
+ const start = route[0].indexOf(route[1]!); peerSlots.add(start);
108
+ let at = route[0].lastIndexOf(route[2]!);
109
+ for (const recipient of route[2]!.split(",")) { peerSlots.add(at); at += recipient.length + 1; }
110
+ }
111
+ if (/^\s*\* task #[0-9]+ .+ (?:accepted by|assigned to) [A-Za-z_][A-Za-z_0-9-]*$/.test(text) && !/ declined| reason:/.test(text)) {
112
+ const owner = text.match(/(?:accepted by|assigned to) ([A-Za-z_][A-Za-z_0-9-]*)$/);
113
+ if (owner) peerSlots.add(owner.index! + owner[0].lastIndexOf(owner[1]!));
114
+ }
115
+ const marker = text.match(/^\s*([.?*!])/);
116
+ const markerAt = marker ? marker[0].length - 1 : -1;
117
+ const state = kind === "state" ? text.match(/ is ([A-Za-z_]+)$/) : undefined;
118
+ const stateAt = state ? state.index! + 4 : -1;
119
+ const permissionIds = new Set<number>();
120
+ for (const match of text.matchAll(/\b(?:permission|request|permit) ([0-9]{8})(?=\b)/g)) permissionIds.add(match.index + match[0].length - 8);
121
+ const taskKeyword = text.match(/^\s*\*?\s*(task)(?![\w-])/i);
122
+ const taskKeywordAt = taskKeyword ? taskKeyword[0].length - taskKeyword[1]!.length : -1;
123
+ const out: Span[] = []; let end = 0; let previousToken = ""; let previousStart = -1;
124
+ for (const match of text.matchAll(/(?<![\p{L}\p{N}_#-])#[0-9]+(?![\p{L}\p{N}_-])|(?<![\p{L}\p{N}_#.:-])(?:[0-9]+(?::[0-9]+)+(?: AM| PM)?|[0-9]+(?:\.[0-9]+)?(?:%|ms|s|m|h)?)(?![\p{L}\p{N}_%-]|[.:][\p{L}\p{N}])|[A-Za-z_][A-Za-z_0-9-]*|->|[.?*!]/gu)) {
125
+ const token = match[0]; const start = match.index;
126
+ const gap = text.slice(end, start);
127
+ if (gap) out.push({ text: gap });
128
+ let tone: Tone | undefined;
129
+ // ponytail: legacy notices have no typed reference ranges. Classify task/review prefixes textually;
130
+ // assign/which #N remains an issue reference until notices carry structured reference spans.
131
+ if (token.startsWith("#")) {
132
+ const task = previousToken.toLowerCase() === "task" && /^\s*$/.test(gap);
133
+ const review = previousToken.toLowerCase() === "review" && /^\s*$/.test(gap) && text[previousStart - 1] === "[" && text[start + token.length] === "]";
134
+ tone = task || review ? "taskRef" : "issueRef";
135
+ }
136
+ else if (start === taskKeywordAt) tone = "taskKeyword";
137
+ else if (/^[0-9]/.test(token) && !permissionIds.has(start)) tone = "number";
138
+ else if (peerSlots.has(start) && Object.hasOwn(peers, token)) tone = peers[token];
139
+ else if (start === markerAt || start === stateAt || (route && token === "->" && start < route[0].length)) tone = eventTone;
140
+ out.push({ text: token, ...(tone ? { tone } : {}) });
141
+ previousToken = token; previousStart = start; end = start + token.length;
142
+ }
143
+ if (end < text.length) out.push({ text: text.slice(end) });
144
+ return out;
145
+ }
146
+ /** Wrap plain text first, then project original token spans onto it, preserving trusted slots across continuations. */
147
+ export function wrapStreamTokens(value: string, columns: number, tone?: Tone, kind?: string): Span[][] {
148
+ const spans = streamTokens(terminalText(value).replace(/\t/g, " "), tone, kind);
149
+ const text = spans.map(s => s.text).join("");
150
+ let cursor = 0; let projecting = true; let spanIndex = 0; let spanAt = 0;
151
+ return wrap(text, columns).map((line, index) => {
152
+ const content = line.trimStart();
153
+ const start = projecting ? text.indexOf(content, cursor) : -1;
154
+ if (!content || start < 0) {
155
+ if (content) projecting = false;
156
+ // Only the first physical header can have structural markers. Never infer structure from a continuation.
157
+ return index === 0 ? streamTokens(line, tone, kind).map(s => ({ text: s.text, ...(s.tone === tone && tone ? { tone } : {}) })) : [{ text: line }];
158
+ }
159
+ const end = start + content.length; cursor = end;
160
+ const out: Span[] = [{ text: line.slice(0, line.length - content.length) }];
161
+ while (spanIndex < spans.length && spanAt + spans[spanIndex]!.text.length <= start) {
162
+ spanAt += spans[spanIndex++]!.text.length;
163
+ }
164
+ while (spanIndex < spans.length && spanAt < end) {
165
+ const span = spans[spanIndex]!; const next = spanAt + span.text.length;
166
+ out.push({ ...span, text: span.text.slice(Math.max(0, start - spanAt), Math.min(span.text.length, end - spanAt)) });
167
+ if (next > end) break;
168
+ spanAt = next; spanIndex++;
169
+ }
170
+ return out;
171
+ });
172
+ }
51
173
  export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
52
174
  if (flag === "always") return true;
53
175
  if (flag === "never") return false;
@@ -188,7 +310,7 @@ const KEYS: [string, string[]][] = [
188
310
  ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
189
311
  ["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
190
312
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
191
- ["Peers", ["p pause", "r resume"]],
313
+ ["Peers", ["p pause", "r resume", "m permission mode"]],
192
314
  ["Tasks", ["a assign (then y)", "r review"]],
193
315
  ["Queue", ["r resolve (reason, then y)"]],
194
316
  ["Events", ["f peer filter", "g kind filter"]],
@@ -222,7 +344,7 @@ function bound(s: ConsoleState): ConsoleState {
222
344
  if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
223
345
  return s;
224
346
  }
225
- const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
347
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
226
348
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
227
349
  export function parseConsoleCommand(input: string): string[] {
228
350
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -241,7 +363,8 @@ export function parseConsoleCommand(input: string): string[] {
241
363
  return words;
242
364
  }
243
365
  export function commandNeedsConfirmation(args: string[]): boolean {
244
- return (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
366
+ const positional = args.filter(arg => arg !== "--yes");
367
+ return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
245
368
  }
246
369
  export function panelRows(s: ConsoleState): any[] {
247
370
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -264,8 +387,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
264
387
  if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
265
388
  if (s.confirm) {
266
389
  const confirm = s.confirm; s.confirm = undefined;
390
+ if (confirm.type === "permission_default") {
391
+ if (key !== "y" && key !== "n" && key !== "\x1b") { s.confirm = confirm; return done(); }
392
+ s.permissionDefaultsHandled = [...s.permissionDefaultsHandled, permissionDefaultFingerprint(s, confirm)];
393
+ if (key === "\x1b") { notify(s, `${confirm.peer} never-ask default deferred; remains pending and ask`, now, "attention"); return done(); }
394
+ effects.push({ type: "permission_default", peer: confirm.peer, confirmed: key === "y" });
395
+ return done();
396
+ }
267
397
  if (key === "y") {
268
- if (confirm.type === "command") effects.push({ type: "command", args: confirm.args });
398
+ if (confirm.type === "command") effects.push({ type: "command", args: confirm.args[0] === "permission" && confirm.args[2] === "never-ask" ? [...confirm.args.filter(arg => arg !== "--yes"), "--yes"] : confirm.args });
269
399
  else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
270
400
  effects.push({ type: "permit", id: confirm.id, option: confirm.option });
271
401
  answered(confirm.id);
@@ -273,6 +403,19 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
273
403
  }
274
404
  return done();
275
405
  }
406
+ if (s.modeChoice) {
407
+ const peer = s.modeChoice;
408
+ if (key === "\x1b") { s.modeChoice = undefined; return done(); }
409
+ const mode = PERMISSION_MODES[Number(key) - 1];
410
+ if (/^[1-3]$/.test(key) && mode) {
411
+ s.modeChoice = undefined;
412
+ if (!s.peers[peer] || s.peers[peer].state === "offline") notify(s, `${peer} is no longer attached`, now);
413
+ else if (permissionModeRefusal(peer, s.peers[peer].permissionMode)) notify(s, permissionModeRefusal(peer, s.peers[peer].permissionMode)!, now);
414
+ else if (mode === "never-ask") s.confirm = { type: "command", args: ["permission", peer, mode] };
415
+ else effects.push({ type: "command", args: ["permission", peer, mode] });
416
+ }
417
+ return done();
418
+ }
276
419
  if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
277
420
  if (s.editing || s.input) {
278
421
  if (key === "\x1b") { s.input = ""; s.editing = false; }
@@ -285,7 +428,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
285
428
  const input = s.input; s.input = ""; s.editing = false;
286
429
  if (!input.trim()) return done();
287
430
  try {
288
- const args = parseConsoleCommand(input);
431
+ const parsed = parseConsoleCommand(input);
432
+ const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
289
433
  if (args[0] === "queue" && args[1] === "resolve") {
290
434
  const reason = args.indexOf("--reason");
291
435
  if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
@@ -351,6 +495,14 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
351
495
  return done();
352
496
  }
353
497
  if (s.mode === "panels" && item !== undefined && item !== null) {
498
+ if (s.panel === 1 && key === "m") {
499
+ const peer = String(item);
500
+ const info = s.peers[peer];
501
+ if (!info || info.state === "offline") notify(s, `${peer} is not attached`, now);
502
+ else if (permissionModeRefusal(peer, info.permissionMode)) notify(s, permissionModeRefusal(peer, info.permissionMode)!, now);
503
+ else s.modeChoice = peer;
504
+ return done();
505
+ }
354
506
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
355
507
  s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
356
508
  s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
@@ -373,12 +525,13 @@ const NOTICE_MS = 10_000;
373
525
  const TABLES: Record<number, string[]> = {
374
526
  1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
375
527
  2: ["ID", "PEER", "LEFT", "TITLE"],
376
- 3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE"],
528
+ 3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
377
529
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
378
530
  };
531
+ const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
379
532
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
380
533
  /** One span per column of a panel row; zero counters and unknown values read `-`. */
381
- function cells(s: ConsoleState, row: any, selected: boolean, now: number): Span[] {
534
+ function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
382
535
  const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
383
536
  if (s.panel === 1) {
384
537
  const b = s.budget[row.id];
@@ -386,6 +539,7 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number): Span[
386
539
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
387
540
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
388
541
  return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.state, stateTone(row.state)),
542
+ ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
389
543
  span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
390
544
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
391
545
  span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
@@ -397,8 +551,10 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number): Span[
397
551
  const last = row.history?.at(-1);
398
552
  const at = last?.at ?? row.updated ?? row.created;
399
553
  const failed = last?.event === "check failed" || last?.event === "failed";
400
- return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}`, failed ? "failure" : row.ready ? "attention" : stateTone(row.state)),
401
- span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title)];
554
+ const stage = stages?.get(row.id);
555
+ const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
556
+ return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
557
+ span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(meter, stage?.changes ? "failure" : stateTone(row.state))];
402
558
  }
403
559
  if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
404
560
  const [header, ...body] = String(row.text).split("\n");
@@ -413,22 +569,33 @@ function table(head: string[], rows: Span[][], columns: number): Span[][] {
413
569
  const cap = Math.max(24, Math.floor(columns / 3));
414
570
  const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
415
571
  const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
572
+ const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
573
+ if (head.includes("STAGE")) {
574
+ // Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
575
+ const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
576
+ for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
577
+ const metadata = head.indexOf(key);
578
+ while (room() < 7 && widths[metadata]! > head[metadata]!.length) widths[metadata] = widths[metadata]! - 1;
579
+ }
580
+ widths[flexible] = Math.max(0, room());
581
+ }
416
582
  return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
417
- if (i === head.length - 1) return cell;
583
+ if (i === head.length - 1 && flexible === i) return cell;
418
584
  const text = fit(cell.text, widths[i]!);
419
- return { ...cell, text: text + " ".repeat(widths[i]! - Bun.stringWidth(text) + 2) };
585
+ return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
420
586
  }));
421
587
  }
422
588
  /** The footer's key hint: only keys that act in this mode, panel and state. */
423
- function hint(s: ConsoleState): string {
589
+ function hint(s: ConsoleState, columns: number): string {
424
590
  const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
425
591
  if (s.mode === "stream") return [...pending, ...(pending.length ? ["v view"] : []), ...(s.approvals.length > (pending.length ? 1 : 0) ? ["[ ] select"] : []), "Tab panels", ": command", "? keys", "q quit"].join(" ");
426
592
  if (s.help) return "? or Esc close q quit";
427
593
  const approvals = s.panel === 2 ? pending : [];
428
594
  if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
429
595
  const rows = panelRows(s); const row = rows[selection(s)];
430
- const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
431
- return [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
596
+ const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume", "m permission mode"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
597
+ const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
598
+ return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
432
599
  }
433
600
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
434
601
  function header(s: ConsoleState, columns: number): Span[] {
@@ -446,45 +613,76 @@ function header(s: ConsoleState, columns: number): Span[] {
446
613
  const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
447
614
  export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
448
615
  const permission = selectedApproval(s);
449
- let prompt = s.editing ? `: ${s.input}` : hint(s);
616
+ let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
450
617
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
451
618
  if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
619
+ if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
452
620
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
453
621
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
454
622
  const summary: Span[] = [];
455
623
  for (const [id, p] of Object.entries(s.peers)) {
456
624
  if (summary.length) summary.push(span(" "));
457
625
  summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
626
+ if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
458
627
  if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
459
628
  if (p.paused) summary.push(span(" paused", "attention"));
460
629
  if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
461
630
  }
462
631
  const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
463
- if (quotas) summary.push(span(` | ${quotas}`));
632
+ if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
464
633
  const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
465
634
  if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
466
635
  else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
467
- if (s.notice && now - (s.noticeAt ?? now) < NOTICE_MS) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
468
- const footer = [fitLine(summary, columns), fitLine(approvals, columns), fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
636
+ const noticeShown = !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
637
+ if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
638
+ const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
639
+ const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
640
+ if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
641
+ const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
642
+ fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
643
+ fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
469
644
  const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
470
- if (s.mode === "stream") return [rule, ...footer];
645
+ if (s.mode === "stream") {
646
+ const counts = s.taskCounts;
647
+ const total = counts ? Object.values(counts).reduce((sum, value) => sum + value, 0) : 0;
648
+ const taskCount = span(counts ? `tasks ${counts.approved ?? 0}/${total} approved` : "tasks loading...", counts ? "success" : "attention");
649
+ if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
650
+ footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
651
+ }
652
+ return [rule, ...footer];
653
+ }
654
+ const progress = taskProgress(s.tasks);
471
655
  const height = rows - 6; // header, rule, body, rule, three footer lines
472
656
  const lines: Span[][] = [];
473
- if (s.help) lines.push(...keyTable(columns));
657
+ if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
658
+ else if (s.help) lines.push(...keyTable(columns));
474
659
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
475
660
  const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
476
661
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
477
662
  }
478
663
  else {
479
- const data = panelRows(s); const head = TABLES[s.panel]; const at = selection(s);
664
+ const data = panelRows(s); const head = s.panel === 1 && showPermissionModes(s) ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : TABLES[s.panel]; const at = selection(s);
480
665
  // Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
481
666
  // shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
482
667
  const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
483
668
  const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
484
669
  if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
485
670
  const tail = [...allow, ...deny];
486
- const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - 1); const start = Math.max(0, at - capacity + 1);
487
- const rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now)), columns) : undefined;
671
+ const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
672
+ const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
673
+ const rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now, stages)), columns) : undefined;
674
+ if (s.panel === 3) {
675
+ const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
676
+ const main = s.tasksKnown === false && !progress.total ? "tasks loading..." : `${progress.counts.approved}/${progress.total} approved [${"#".repeat(done)}${".".repeat(20 - done)}] ${progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0}%`;
677
+ const known = s.tasksKnown !== false || progress.total > 0;
678
+ const parts = [span(main, known ? "success" : "attention")];
679
+ for (const [label, value, tone] of [["changes", progress.counts.changes_requested, "failure"], ["review", progress.counts.in_review, "attention"], ["waiting", progress.counts.waiting, "attention"], ["in progress", progress.counts.in_progress, undefined], ["proposed", progress.counts.proposed, undefined], ...(progress.counts.unknown ? [["unknown", progress.counts.unknown, "attention"] as const] : [])] as const) {
680
+ const extra = ` ${label} ${value}`;
681
+ if (!known || Bun.stringWidth(parts.map(p => p.text).join("") + extra) > columns) break;
682
+ parts.push(span(extra, tone));
683
+ }
684
+ lines.push(fitLine(parts, columns));
685
+ }
488
686
  lines.push(fitLine([span(" "), ...(rowsOf?.[0] ?? [span(`peer ${s.peerFilter ?? "all"} kind ${s.kindFilter ?? "all"}`, "info")])], columns));
489
687
  for (let i = start; i < Math.min(data.length, start + capacity); i++) {
490
688
  const selected = i === at;
@@ -4,7 +4,7 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import type { ControlClient } from "../hub/control-client.ts";
5
5
  import { contextLine } from "./status-lines.ts";
6
6
  import { renderTailEvent } from "./tail-render.ts";
7
- import { approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, terminalText, wrap } 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
8
  import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
9
9
  import type { BusEvent } from "../hub/bus.ts";
10
10
 
@@ -76,7 +76,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
76
76
  let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
77
77
  const notice = (text: string) => notify(state, text, Date.now());
78
78
  const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
79
- 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 }]));
80
80
  const writeStream = (event: ConsoleEvent) => {
81
81
  terminal.write(`\x1b[${rows - 4};1H`); // the scroll region ends above the rule and the three footer lines
82
82
  for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
@@ -86,7 +86,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
86
86
  if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
87
87
  if (plain) {
88
88
  const [header, ...body] = terminalText(event.text).split("\n");
89
- 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");
90
90
  }
91
91
  else if (state.mode === "stream") writeStream(event);
92
92
  else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
@@ -130,11 +130,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
130
130
  ]);
131
131
  if (!active) return;
132
132
  const [status, budget, tasks, queue, ready] = replies;
133
+ if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
133
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" });
134
136
  if (budget.budget) state.budget = budget.budget;
135
- 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)) {
136
138
  const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
137
139
  state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
140
+ state.tasksKnown = true;
138
141
  } }
139
142
  if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
140
143
  const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
@@ -156,6 +159,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
156
159
  });
157
160
  const effect = async (action: ConsoleEffect) => {
158
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
+ }
159
170
  if (action.type === "permit") {
160
171
  client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
161
172
  if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
@@ -221,6 +232,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
221
232
  // Under the modal key table it does nothing at all.
222
233
  if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
223
234
  if (state.help && state.mode === "panels") return;
235
+ if (state.confirm?.type === "permission_default") { draw(); return; }
224
236
  state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
225
237
  state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
226
238
  } else handle(text);
@@ -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
  }