@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,24 +1,75 @@
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
- mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
13
+ mode: "stream" | "panels"; panel: number; selection: number;
14
+ /** The selected request, by id: a, d and v act on it alone, and it is cleared, never moved, when the request closes. */
15
+ approvalId?: string;
16
+ /** The request whose detail is open: always the selected one (see `bound`). */
17
+ requestDetail?: string;
18
+ /** A request was selected on arrival once; after that only [ ], j or k select, so a closed selection never moves. */
19
+ autoSelected?: boolean;
10
20
  input: string; editing: boolean; history: string[]; historyIndex: number;
11
21
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
12
- tasks: any[]; queue: any[]; detail?: string; detailOffset: number; help: boolean; notice: string;
13
- peerFilter?: string; kindFilter?: string;
14
- confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
22
+ tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
23
+ peerFilter?: string; kindFilter?: string; project?: string;
24
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
15
25
  optionChoice?: string;
26
+ modeChoice?: string;
27
+ permissionDefaults: PermissionDefault[];
28
+ permissionDefaultsHandled: string[];
16
29
  }
30
+ /** A detail view: text as the stream shows it, or labeled fields. */
31
+ export type Detail = string | Record<string, unknown>;
17
32
  export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
18
- { type: "command"; args: string[] } | { type: "show"; panel: number; id: string };
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" };
19
34
  export function initialConsoleState(panels = false): ConsoleState {
20
- return { mode: panels ? "panels" : "stream", panel: 1, selection: 0, approvalIndex: 0,
21
- input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
35
+ return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
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.`;
22
73
  }
23
74
  /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
24
75
  export function terminalText(value: unknown): string {
@@ -28,10 +79,12 @@ export function terminalText(value: unknown): string {
28
79
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
29
80
  .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
30
81
  }
31
- 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";
32
83
  export interface Span { text: string; tone?: Tone }
33
84
  export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
34
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",
35
88
  });
36
89
  export function paint(line: Span[], color: boolean): string {
37
90
  return line.map(span => {
@@ -40,6 +93,83 @@ export function paint(line: Span[], color: boolean): string {
40
93
  return color && sgr && text ? sgr + text + "\x1b[0m" : text;
41
94
  }).join("");
42
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
+ }
43
173
  export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
44
174
  if (flag === "always") return true;
45
175
  if (flag === "never") return false;
@@ -53,46 +183,168 @@ export function stateTone(state: string): Tone | undefined {
53
183
  return undefined;
54
184
  }
55
185
  const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
56
- /** Clip only sanitized plain text, then retain the tones of the surviving prefix. */
186
+ /** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
187
+ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
188
+ /**
189
+ * Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
190
+ * prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
191
+ */
57
192
  function fitLine(line: Span[], columns: number): Span[] {
58
- const clean = line.map(s => ({ ...s, text: terminalText(s.text).replace(/\n/g, " ") }));
59
- const text = clean.map(s => s.text).join("");
60
- const clipped = fit(text, columns);
61
- const marker = text === clipped ? "" : ".".repeat(Math.max(0, Math.min(3, columns)));
62
- let remaining = clipped.length - marker.length;
63
- const out: Span[] = [];
64
- for (const s of clean) { const part = s.text.slice(0, Math.max(0, remaining)); if (part) out.push({ ...s, text: part }); remaining -= part.length; }
193
+ const clean = line.map(s => ({ ...s, text: flat(s.text) }));
194
+ if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
195
+ const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
196
+ const out: Span[] = []; let room = columns - marker.length;
197
+ for (const s of clean) {
198
+ let part = "";
199
+ for (const char of s.text) { if (Bun.stringWidth(sanitize(part + char)) > room) break; part += char; }
200
+ if (part) out.push({ ...s, text: part });
201
+ if (part !== s.text) break;
202
+ room -= Bun.stringWidth(part);
203
+ }
65
204
  if (marker) out.push(span(marker));
66
205
  return out;
67
206
  }
68
207
  export function fit(value: unknown, columns: number): string {
69
- const text = terminalText(value).replace(/\n/g, " ");
70
- if (columns <= 0) return "";
71
- if (Bun.stringWidth(text) <= columns) return text;
72
- let result = ""; const marker = ".".repeat(Math.min(3, columns));
73
- for (const char of text) { if (Bun.stringWidth(result + char) > columns - marker.length) break; result += char; }
74
- return result + marker;
75
- }
76
- export function wrap(value: unknown, columns: number): string[] {
208
+ return paint(fitLine([span(value)], columns), false);
209
+ }
210
+ /**
211
+ * A source line keeps its indent and its continuations hang `hang` columns deeper, both capped at half the width, so
212
+ * below that cap wrapped untrusted text never starts where its source line, or a hub line at that indent, starts. Breaks fall
213
+ * at whitespace, which they drop; only a word longer than a whole line is split. A tab counts as one space.
214
+ * Lines come out sanitized: a width counts the `> ` that sanitize() puts before a line starting like a hub header.
215
+ */
216
+ export function wrap(value: unknown, columns: number, hang = 4): string[] {
77
217
  const lines: string[] = [];
78
- for (const part of terminalText(value).split("\n")) {
79
- let line = "";
80
- for (const char of part) {
81
- if (Bun.stringWidth(line + char) > columns) { lines.push(line); line = ""; }
82
- line += char;
218
+ const half = Math.floor(columns / 2);
219
+ const width = (text: string) => Bun.stringWidth(sanitize(text));
220
+ for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
221
+ const indent = /^\s*/.exec(part)![0];
222
+ const lead = Math.min(Bun.stringWidth(indent), half);
223
+ const pad = " ".repeat(Math.min(lead + hang, half));
224
+ const first = lines.length;
225
+ let start = " ".repeat(lead);
226
+ let line = start;
227
+ const push = () => { lines.push(sanitize(line.trimEnd())); line = start = pad; };
228
+ for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
229
+ if (width(line + token) <= columns) { line += token; continue; }
230
+ if (line !== start) push();
231
+ if (/^\s/.test(token)) continue;
232
+ for (const char of token) {
233
+ if (line !== start && width(line + char) > columns) push();
234
+ line += char;
235
+ }
83
236
  }
84
- lines.push(line);
237
+ if (line !== start || lines.length === first) lines.push(sanitize(line.trimEnd()));
238
+ }
239
+ return lines;
240
+ }
241
+ export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
242
+ /** `45s`, `12m`, `3h05m`, `2d03h`. */
243
+ export function duration(ms: number): string {
244
+ const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
245
+ return s < 60 ? `${s}s` : m < 60 ? `${m}m` : h < 48 ? `${h}h${String(m % 60).padStart(2, "0")}m` : `${Math.floor(h / 24)}d${String(h % 24).padStart(2, "0")}h`;
246
+ }
247
+ /** A moment as `in 2h13m` or `12m ago`. */
248
+ export const relative = (at: number, now: number) => at > now ? `in ${duration(at - now)}` : `${duration(now - at)} ago`;
249
+ const WORD = /^\p{L}[\p{L}\p{N}_./:@#+-]*$/u; // a letter first: `1 2` must not read as two numbers
250
+ /**
251
+ * An agent-written string inside hub-built text (a detail below its field, an option name in a prompt): a plain word as
252
+ * it is, anything else JSON-quoted, so no `;`, `,`, `)`, quote or newline in it can end it and pass for hub structure.
253
+ */
254
+ export const quoted = (text: unknown) => { const value = String(text ?? ""); return WORD.test(value) ? value : JSON.stringify(value); };
255
+ const TIME_KEY = /^(?:at|created|updated|since|expires)$|At$/;
256
+ /**
257
+ * A detail value as text, never JSON: times relative, shares as percent, objects as `key value` pairs, nested lists
258
+ * inline. Only a field's own string value shows as written; every string below it goes through `quoted`.
259
+ */
260
+ function detailText(value: unknown, key: string, now: number, nested = false): string {
261
+ if (value === null || value === undefined || (value === "" && !nested)) return "-";
262
+ if (typeof value === "number" && TIME_KEY.test(key)) return relative(value, now);
263
+ if (typeof value === "number" && key === "used") return `${Math.round(value * 100)}%`;
264
+ if (Array.isArray(value)) return value.map(item => item && typeof item === "object" && !Array.isArray(item) ? `(${detailText(item, key, now, true)})` : detailText(item, key, now, true)).join(", ") || "-";
265
+ if (typeof value === "object") return Object.entries(value).map(([k, v]) => `${k} ${detailText(v, k, now, true)}`).join("; ") || "-";
266
+ return nested ? quoted(value) : String(value);
267
+ }
268
+ /**
269
+ * Labels at column 0, values one column past the longest label. A list puts the hub's `- ` before each item, and every
270
+ * other line of a value (its own newlines, wrapped continuations) starts two columns deeper: untrusted text can pass
271
+ * neither for a label nor for an item. Allow options keep one line each, cut rather than wrapped.
272
+ */
273
+ function fieldLines(fields: [string, unknown][], columns: number, now: number): Span[][][] {
274
+ const pad = Math.min(16, Math.max(0, ...fields.map(([label]) => Bun.stringWidth(label))) + 2);
275
+ const width = columns - pad - 2;
276
+ return fields.map(([label, value]) => {
277
+ const list = Array.isArray(value) && value.length > 0;
278
+ const items = list ? (value as unknown[]).map(item => detailText(item, label, now, true)) : [detailText(value, label, now)];
279
+ const lines = items.flatMap(item => (label === "a allow" ? [fit(item, width)] : wrap(item, width)).map((text, i) => (i ? " " : list ? "- " : "") + text));
280
+ return lines.map((text, i) => {
281
+ const head = i ? "" : fit(label, pad - 2);
282
+ return [span(head + " ".repeat(pad - Bun.stringWidth(head)), i ? undefined : "info"), span(text)];
283
+ });
284
+ });
285
+ }
286
+ const detailLines = (detail: Detail, columns: number, now: number): Span[][] =>
287
+ typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(Object.entries(detail), columns, now).flat();
288
+ const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
289
+ /** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
290
+ export function approvalFrom(msg: any): Approval {
291
+ return { id: String(msg.id ?? ""), peer: String(msg.peer ?? ""), title: String(msg.title ?? ""), expiresAt: Number(msg.expiresAt),
292
+ options: (Array.isArray(msg.options) ? msg.options : []).map((o: any) => ({ optionId: String(o?.optionId ?? ""), name: String(o?.name ?? ""), kind: String(o?.kind ?? "") })) };
293
+ }
294
+ /** Errors and refusals take the failure tone; a notice that only informs passes `attention`. */
295
+ export function notify(s: ConsoleState, text: string, now: number, tone: Tone = "failure"): void {
296
+ s.notice = text; s.noticeAt = now; s.noticeTone = tone;
297
+ }
298
+ /** A request as labeled fields: allow options numbered as `a` offers them (`{ 2: name }`: the number is the hub's, the name quoted), then deny. */
299
+ function approvalFields(a: Approval): [string, unknown][] {
300
+ const allow = allowOptions(a);
301
+ return [["request", a.id], ["peer", a.peer], ["expires", a.expiresAt], ["title", a.title],
302
+ ["a allow", allow.length > 1 ? allow.map((o, i) => ({ [i + 1]: o.name })) : allow[0] && quoted(allow[0].name)], ["d deny", "at once, no confirmation"]];
303
+ }
304
+ /** How the stream shows a permission request: hub text at columns 2 and 4, title lines framed at column 6. */
305
+ export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
306
+ return ` ? ${a.peer} asks permission: ${String(a.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${a.id} <${a.options.map(o => `${quoted(o.optionId)} (${quoted(o.name)})`).join(", ")}> | deny`;
307
+ }
308
+ const KEYS: [string, string[]][] = [
309
+ ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
310
+ ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
311
+ ["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
312
+ ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
313
+ ["Peers", ["p pause", "r resume", "m permission mode"]],
314
+ ["Tasks", ["a assign (then y)", "r review"]],
315
+ ["Queue", ["r resolve (reason, then y)"]],
316
+ ["Events", ["f peer filter", "g kind filter"]],
317
+ ];
318
+ /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
319
+ export function keyTable(columns: number): Span[][] {
320
+ const lines: Span[][] = []; const pad = 12;
321
+ for (const [group, items] of KEYS) {
322
+ let text = ""; let head = group;
323
+ const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
324
+ for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
325
+ flush();
85
326
  }
86
327
  return lines;
87
328
  }
88
329
  export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
89
330
  const approvals = s.approvals.filter(a => a.expiresAt > now);
90
331
  const live = (id: string) => approvals.some(a => a.id === id);
91
- return { ...s, approvals, approvalIndex: Math.min(s.approvalIndex, Math.max(0, approvals.length - 1)),
92
- ...(s.confirm?.type === "permission" && !live(s.confirm.id) ? { confirm: undefined } : {}),
93
- ...(s.optionChoice && !live(s.optionChoice) ? { optionChoice: undefined } : {}) };
332
+ const gone = s.approvalId !== undefined && !live(s.approvalId);
333
+ return bound({ ...s, approvals,
334
+ ...(gone ? { approvalId: undefined, notice: `request ${s.approvalId} closed${approvals.length ? "; [ ] selects another" : ""}`, noticeAt: now, noticeTone: "attention" as const } : {}) });
94
335
  }
95
- const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
336
+ /**
337
+ * Everything pending on a request belongs to the selected one: a choice or confirmation for any other request is
338
+ * dropped, and an open detail follows the selection from its top (it closes when nothing is selected). Every reducer
339
+ * exit and every prune passes through here, so no key can act on a request other than the one shown.
340
+ */
341
+ function bound(s: ConsoleState): ConsoleState {
342
+ if (s.optionChoice && s.optionChoice !== s.approvalId) s.optionChoice = undefined;
343
+ if (s.confirm?.type === "permission" && s.confirm.id !== s.approvalId) s.confirm = undefined;
344
+ if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
345
+ return s;
346
+ }
347
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
96
348
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
97
349
  export function parseConsoleCommand(input: string): string[] {
98
350
  const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
@@ -111,7 +363,8 @@ export function parseConsoleCommand(input: string): string[] {
111
363
  return words;
112
364
  }
113
365
  export function commandNeedsConfirmation(args: string[]): boolean {
114
- 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");
115
368
  }
116
369
  export function panelRows(s: ConsoleState): any[] {
117
370
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -123,21 +376,47 @@ export function panelRows(s: ConsoleState): any[] {
123
376
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
124
377
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
125
378
  }
379
+ export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
380
+ /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
381
+ const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
126
382
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
127
- let s = pruneApprovals({ ...state }, now); const effects: ConsoleEffect[] = [];
128
- const done = () => ({ state: s, effects });
383
+ // A notice answers the key before this one; one that pruning raises now stays.
384
+ let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
385
+ const done = () => ({ state: bound(s), effects });
386
+ const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
129
387
  if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
130
388
  if (s.confirm) {
131
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
+ }
132
397
  if (key === "y") {
133
- if (confirm.type === "command") effects.push({ type: "command", args: confirm.args });
134
- else if (s.approvals.some(a => a.id === confirm.id && a.options.some(o => o.optionId === confirm.option && o.kind.startsWith("allow")))) {
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 });
399
+ else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
135
400
  effects.push({ type: "permit", id: confirm.id, option: confirm.option });
136
- s.approvals = s.approvals.filter(a => a.id !== confirm.id);
401
+ answered(confirm.id);
137
402
  }
138
403
  }
139
404
  return done();
140
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
+ }
419
+ if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
141
420
  if (s.editing || s.input) {
142
421
  if (key === "\x1b") { s.input = ""; s.editing = false; }
143
422
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
@@ -149,7 +428,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
149
428
  const input = s.input; s.input = ""; s.editing = false;
150
429
  if (!input.trim()) return done();
151
430
  try {
152
- 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;
153
433
  if (args[0] === "queue" && args[1] === "resolve") {
154
434
  const reason = args.indexOf("--reason");
155
435
  if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
@@ -157,52 +437,75 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
157
437
  s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
158
438
  if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
159
439
  else effects.push({ type: "command", args });
160
- } catch (error) { s.notice = String((error as Error).message); }
440
+ } catch (error) { notify(s, String((error as Error).message), now); }
161
441
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
162
442
  return done();
163
443
  }
164
444
  if (key === "q") { effects.push({ type: "exit" }); return done(); }
165
- if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; return done(); }
445
+ if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
166
446
  if (key === ":") { s.editing = true; return done(); }
167
- if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; return done(); }
168
- if (key === "?") { s.help = !s.help; return done(); }
169
- if (key === "\x1b") { s.detail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
447
+ if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); }
448
+ if (key === "?") {
449
+ if (s.mode === "stream") effects.push({ type: "keys" });
450
+ else s.help = !s.help;
451
+ return done();
452
+ }
453
+ if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
170
454
  if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
171
- if (s.detail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
172
- s.selection = Math.max(0, Math.min(panelRows(s).length - 1, s.selection + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
173
- if (s.panel === 2) s.approvalIndex = s.selection;
455
+ if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
456
+ s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
457
+ if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
174
458
  return done();
175
459
  }
176
460
  if (key === "[" || key === "]") {
177
- s.approvalIndex = (s.approvalIndex + (key === "[" ? -1 : 1) + s.approvals.length) % Math.max(1, s.approvals.length); return done();
461
+ const i = s.approvals.findIndex(a => a.id === s.approvalId), n = s.approvals.length;
462
+ if (n) s.approvalId = s.approvals[i < 0 ? (key === "]" ? 0 : n - 1) : (i + (key === "[" ? -1 : 1) + n) % n]!.id;
463
+ return done();
178
464
  }
179
- const approval = s.approvals[s.approvalIndex];
465
+ const approval = selectedApproval(s);
180
466
  if (s.optionChoice) {
181
- const a = s.approvals.find(a => a.id === s.optionChoice); const options = a?.options.filter(o => o.kind.startsWith("allow")) ?? [];
182
- const option = options[Number(key) - 1];
467
+ const a = s.approvals.find(a => a.id === s.optionChoice);
468
+ const option = a && allowOptions(a)[Number(key) - 1];
183
469
  if (/^[1-9]$/.test(key) && a && option) { s.confirm = { type: "permission", id: a.id, peer: a.peer, option: option.optionId }; s.optionChoice = undefined; }
184
470
  return done();
185
471
  }
186
- if (approval && (s.mode === "stream" || s.panel === 2)) {
187
- if (key === "d") { effects.push({ type: "permit", id: approval.id }); s.approvals = s.approvals.filter(a => a.id !== approval.id); return done(); }
472
+ const requestShown = s.mode === "stream" || (s.panel === 2 && s.detail === undefined);
473
+ if (!approval && s.approvals.length && requestShown && ["a", "d", "v"].includes(key)) {
474
+ notify(s, "no request selected; [ ] selects one", now, "attention"); return done();
475
+ }
476
+ if (approval && requestShown) {
477
+ if (key === "d") { effects.push({ type: "permit", id: approval.id }); answered(approval.id); return done(); }
188
478
  if (key === "a") {
189
- const options = approval.options.filter(o => o.kind.startsWith("allow"));
479
+ const options = allowOptions(approval);
190
480
  if (options.length === 1) s.confirm = { type: "permission", id: approval.id, peer: approval.peer, option: options[0]!.optionId };
191
481
  else if (options.length) s.optionChoice = approval.id;
192
482
  return done();
193
483
  }
194
- if (key === "v") { s.detail = approval.title; s.detailOffset = 0; return done(); }
484
+ if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
485
+ if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
195
486
  }
196
- const row = panelRows(s)[s.selection];
197
- if (s.mode === "panels" && row) {
198
- if (key === "\r" || key === "\n") {
199
- if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
200
- else { s.detail = s.panel === 2 ? row.title : s.panel === 5 ? row.text : JSON.stringify(row, null, 2); s.detailOffset = 0; }
487
+ const row = panelRows(s)[selection(s)];
488
+ // With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
489
+ // a detail that is only a message names no item.
490
+ const item = s.detail === undefined ? row?.id : typeof s.detail === "object" ? s.detail.id : undefined;
491
+ if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
492
+ if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
493
+ else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
494
+ else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
495
+ return done();
496
+ }
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;
201
504
  return done();
202
505
  }
203
- const prefill = s.panel === 1 && key === "p" ? `pause ${row.id}` : s.panel === 1 && key === "r" ? `resume ${row.id}` :
204
- s.panel === 3 && key === "a" ? `task assign ${row.id} ` : s.panel === 3 && key === "r" ? `review ${row.id} ` :
205
- s.panel === 4 && key === "r" ? `queue resolve ${row.id} --action retry --reason ` : undefined;
506
+ const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
507
+ s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
508
+ s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
206
509
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
207
510
  }
208
511
  if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
@@ -218,62 +521,180 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
218
521
  export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
219
522
  return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
220
523
  }
524
+ const NOTICE_MS = 10_000;
525
+ const TABLES: Record<number, string[]> = {
526
+ 1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
527
+ 2: ["ID", "PEER", "LEFT", "TITLE"],
528
+ 3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
529
+ 4: ["ID", "PEER", "STATE", "REV", "AGE"],
530
+ };
531
+ const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
532
+ const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
533
+ /** One span per column of a panel row; zero counters and unknown values read `-`. */
534
+ function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
535
+ const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
536
+ if (s.panel === 1) {
537
+ const b = s.budget[row.id];
538
+ const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
539
+ ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
540
+ : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
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)] : []),
543
+ span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
544
+ span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
545
+ span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
546
+ span(b?.windows?.length ? b.windows.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${relative(w.resetsAt, now)}` : ""}`).join(", ") : "-"),
547
+ span(row.servedBy ?? row.requestedModel ?? "-")];
548
+ }
549
+ if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
550
+ if (s.panel === 3) {
551
+ const last = row.history?.at(-1);
552
+ const at = last?.at ?? row.updated ?? row.created;
553
+ const failed = last?.event === "check failed" || last?.event === "failed";
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))];
558
+ }
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")];
560
+ const [header, ...body] = String(row.text).split("\n");
561
+ return [span(header, row.tone), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
562
+ }
563
+ /**
564
+ * A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
565
+ * widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
566
+ * takes the rest.
567
+ */
568
+ function table(head: string[], rows: Span[][], columns: number): Span[][] {
569
+ const cap = Math.max(24, Math.floor(columns / 3));
570
+ const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
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
+ }
582
+ return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
583
+ if (i === head.length - 1 && flexible === i) return cell;
584
+ const text = fit(cell.text, widths[i]!);
585
+ return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
586
+ }));
587
+ }
588
+ /** The footer's key hint: only keys that act in this mode, panel and state. */
589
+ function hint(s: ConsoleState, columns: number): string {
590
+ const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
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(" ");
592
+ if (s.help) return "? or Esc close q quit";
593
+ const approvals = s.panel === 2 ? pending : [];
594
+ if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
595
+ const rows = panelRows(s); const row = rows[selection(s)];
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;
599
+ }
600
+ /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
601
+ function header(s: ConsoleState, columns: number): Span[] {
602
+ const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
603
+ const tabs: Span[] = [];
604
+ for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events"].entries()) {
605
+ const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
606
+ tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
607
+ if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
608
+ tabs.push(span(active ? "]" : " ", tone));
609
+ }
610
+ return fitLine([span(fit(s.project ?? "agent-hub", Math.max(0, columns - Bun.stringWidth(tabs.map(t => t.text).join("")))), "info"), ...tabs], columns);
611
+ }
612
+ /** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
613
+ const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
221
614
  export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
222
- const permission = s.approvals[s.approvalIndex];
223
- let prompt = s.editing ? `: ${s.input}` : "q quit | Tab panels | : command | a allow d deny v view [ ] select";
224
- if (s.confirm?.type === "permission") prompt = `allow ${s.confirm.option} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
615
+ const permission = selectedApproval(s);
616
+ let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
617
+ if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
225
618
  if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
226
- if (s.optionChoice) prompt = s.approvals.find(a => a.id === s.optionChoice)?.options.filter(o => o.kind.startsWith("allow")).map((o, i) => `${i + 1}:${o.name}`).join(" | ") ?? "";
619
+ if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
620
+ const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
621
+ if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
227
622
  const summary: Span[] = [];
228
623
  for (const [id, p] of Object.entries(s.peers)) {
229
624
  if (summary.length) summary.push(span(" "));
230
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"));
231
627
  if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
232
628
  if (p.paused) summary.push(span(" paused", "attention"));
629
+ if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
233
630
  }
234
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(" ");
235
- if (quotas) summary.push(span(` | ${quotas}`));
236
- const approvals = [span(`${s.approvals.length} approvals`, s.approvals.length ? "attention" : undefined)];
237
- if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${Math.max(0, Math.ceil((permission.expiresAt - now) / 1000))}s`, "muted"));
238
- if (s.notice) approvals.push(span(` | ${s.notice}`));
239
- const footer = [fitLine(summary, columns), fitLine(approvals, columns), fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
240
- if (s.mode === "stream") return footer;
241
- const titles = ["Peers", "Approvals", "Tasks", "Queue", "Events"];
242
- const title = titles[s.panel - 1];
243
- const tabs = [span("agent-hub | ", "info")];
244
- for (const [i, name] of titles.entries()) tabs.push(span(`${i + 1} ${name}${i < 4 ? " " : ""}`, i + 1 === s.panel ? "strong" : "info"));
245
- tabs.push(span(` | ${title}`, "strong"));
246
- const lines: Span[][] = [fitLine(tabs, columns), fitLine([span(`j/k move Enter detail Esc back ? keys${s.panel === 5 ? ` | f peer:${s.peerFilter ?? "all"} g kind:${s.kindFilter ?? "all"}` : ""}`)], columns)];
247
- if (s.help) lines.push(...wrap("Tab stream/panels; 1-5 panel; j/k or arrows move; Enter detail; Esc back; : command; q quit. Approvals: a allow (confirm y), d deny, v title, [ ] select. Peers: p pause, r resume. Tasks: a assign (confirm y), r review. Queue: r resolve (reason and confirm y). Events: f peer filter, g kind filter.", columns).map(text => [span(text)]));
248
- else if (s.detail) { const detail = wrap(s.detail, columns); lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - (rows - 5)))).map(text => [span(text)])); }
632
+ if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
633
+ const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
634
+ if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
635
+ else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
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)];
644
+ const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
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);
655
+ const height = rows - 6; // header, rule, body, rule, three footer lines
656
+ const lines: Span[][] = [];
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));
659
+ else if (s.detail !== undefined || (s.requestDetail && permission)) {
660
+ const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
661
+ lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
662
+ }
249
663
  else {
250
- const data = panelRows(s); const capacity = Math.max(1, s.panel === 2 ? Math.floor((rows - 6) / 2) : rows - 6); const start = Math.max(0, s.selection - capacity + 1);
251
- for (const [i, row] of data.slice(start, start + capacity).entries()) {
252
- const selected = start + i === s.selection;
253
- const line = [span(`${selected ? ">" : " "} `, selected ? "strong" : undefined)];
254
- if (s.panel === 1) {
255
- const b = s.budget[row.id];
256
- line.push(span(row.id, row.state === "offline" ? undefined : selected ? "strong" : "info"), span(" "), span(row.state, stateTone(row.state)),
257
- span(` ${row.attached === false ? "detached" : "attached"} q:${row.queued ?? 0} `), span(`!:${row.queuedImportant ?? 0}`, row.queuedImportant ? "attention" : undefined),
258
- span(` review:${row.needsReview ?? 0}`, row.needsReview ? "failure" : undefined), span(" "),
259
- span(row.paused ? `paused:${JSON.stringify(row.paused)}` : "", "attention"), span(" "),
260
- span(b?.paused ? `budget pause ${b.paused.reason} reset:${b.paused.resetsAt}` : "", "attention"), span(" "),
261
- span(b?.windows?.map((w: any) => `${w.id}:${Math.round(w.used * 100)}% reset:${w.resetsAt ?? "?"}`).join(" ") ?? "quota:?"), span(` model:${row.servedBy ?? row.requestedModel ?? "?"}`));
262
- } else if (s.panel === 2) line.push(span(`${row.id} ${row.peer}`, "attention"), span(` ${Math.max(0, Math.ceil((row.expiresAt - now) / 1000))}s`, "muted"), span(` ${row.title}`));
263
- else if (s.panel === 3) {
264
- const last = row.history?.at(-1);
265
- const at = last?.at ?? row.updated ?? row.created;
266
- const tone = last?.event === "check failed" || last?.event === "failed" ? "failure" : stateTone(row.state);
267
- const failure = (last?.event === "check failed" || last?.event === "failed") && row.state !== last.event ? ` ${last.event}` : "";
268
- line.push(span(`#${row.id}`, selected ? "strong" : "info"), span(" "), span(row.state, tone), span(failure, "failure"), span(row.ready ? " ready" : "", "attention"),
269
- span(` ${row.owner ?? "-"} review:${row.reviewer ?? "-"} ${row.class} `), span(`age:${at ? Math.max(0, Math.floor((now - at) / 1000)) + "s" : "?"}`, "muted"), span(` ${row.title}`));
270
- } else if (s.panel === 4) line.push(span(row.id, selected ? "strong" : "info"), span(` ${row.peer} `), span(row.state, stateTone(row.state)), span(` rev:${row.revision} `), span(`age:${row.createdAt ? Math.max(0, Math.floor((now - row.createdAt) / 1000)) + "s" : "?"}`, "muted"));
271
- else { const [header, ...body] = String(row.text).split("\n"); line.push(span(header, row.tone)); if (body.length) line.push(span("\n" + body.join("\n"))); }
272
- lines.push(fitLine(line, columns));
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);
665
+ // Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
666
+ // shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
667
+ const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
668
+ const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
669
+ if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
670
+ const tail = [...allow, ...deny];
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
+ }
686
+ lines.push(fitLine([span(" "), ...(rowsOf?.[0] ?? [span(`peer ${s.peerFilter ?? "all"} kind ${s.kindFilter ?? "all"}`, "info")])], columns));
687
+ for (let i = start; i < Math.min(data.length, start + capacity); i++) {
688
+ const selected = i === at;
689
+ lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
690
+ }
691
+ if (!data.length) lines.push([span(" (empty)")]);
692
+ if (title.length) {
693
+ const room = Math.max(1, height - lines.length - 1 - tail.length);
694
+ if (title.length > room) { title.length = room; title[room - 1] = more(title[room - 1]!); }
695
+ lines.push(rule, ...title, ...tail);
273
696
  }
274
- if (!data.length) lines.push([span("(empty)")]);
275
- if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)).map(text => [span(text)]));
276
697
  }
277
- const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push([]);
278
- return [...body, ...footer];
698
+ const body = lines.slice(0, height); while (body.length < height) body.push([]);
699
+ return [header(s, columns), rule, ...body, rule, ...footer];
279
700
  }