@staix/agent-hub 0.12.19 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +413 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +17 -10
  12. package/docs/smoke.md +58 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +56 -9
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
@@ -6,18 +6,26 @@ export interface Approval {
6
6
  }
7
7
  export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
8
8
  export interface ConsoleState {
9
- mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
9
+ mode: "stream" | "panels"; panel: number; selection: number;
10
+ /** The selected request, by id: a, d and v act on it alone, and it is cleared, never moved, when the request closes. */
11
+ approvalId?: string;
12
+ /** The request whose detail is open: always the selected one (see `bound`). */
13
+ requestDetail?: string;
14
+ /** A request was selected on arrival once; after that only [ ], j or k select, so a closed selection never moves. */
15
+ autoSelected?: boolean;
10
16
  input: string; editing: boolean; history: string[]; historyIndex: number;
11
17
  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;
18
+ tasks: any[]; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
19
+ peerFilter?: string; kindFilter?: string; project?: string;
14
20
  confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
15
21
  optionChoice?: string;
16
22
  }
23
+ /** A detail view: text as the stream shows it, or labeled fields. */
24
+ export type Detail = string | Record<string, unknown>;
17
25
  export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
18
- { type: "command"; args: string[] } | { type: "show"; panel: number; id: string };
26
+ { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
19
27
  export function initialConsoleState(panels = false): ConsoleState {
20
- return { mode: panels ? "panels" : "stream", panel: 1, selection: 0, approvalIndex: 0,
28
+ return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
21
29
  input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
22
30
  }
23
31
  /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
@@ -53,44 +61,166 @@ export function stateTone(state: string): Tone | undefined {
53
61
  return undefined;
54
62
  }
55
63
  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. */
64
+ /** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
65
+ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
66
+ /**
67
+ * Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
68
+ * prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
69
+ */
57
70
  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; }
71
+ const clean = line.map(s => ({ ...s, text: flat(s.text) }));
72
+ if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
73
+ const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
74
+ const out: Span[] = []; let room = columns - marker.length;
75
+ for (const s of clean) {
76
+ let part = "";
77
+ for (const char of s.text) { if (Bun.stringWidth(sanitize(part + char)) > room) break; part += char; }
78
+ if (part) out.push({ ...s, text: part });
79
+ if (part !== s.text) break;
80
+ room -= Bun.stringWidth(part);
81
+ }
65
82
  if (marker) out.push(span(marker));
66
83
  return out;
67
84
  }
68
85
  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;
86
+ return paint(fitLine([span(value)], columns), false);
75
87
  }
76
- export function wrap(value: unknown, columns: number): string[] {
88
+ /**
89
+ * A source line keeps its indent and its continuations hang `hang` columns deeper, both capped at half the width, so
90
+ * below that cap wrapped untrusted text never starts where its source line, or a hub line at that indent, starts. Breaks fall
91
+ * at whitespace, which they drop; only a word longer than a whole line is split. A tab counts as one space.
92
+ * Lines come out sanitized: a width counts the `> ` that sanitize() puts before a line starting like a hub header.
93
+ */
94
+ export function wrap(value: unknown, columns: number, hang = 4): string[] {
77
95
  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;
96
+ const half = Math.floor(columns / 2);
97
+ const width = (text: string) => Bun.stringWidth(sanitize(text));
98
+ for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
99
+ const indent = /^\s*/.exec(part)![0];
100
+ const lead = Math.min(Bun.stringWidth(indent), half);
101
+ const pad = " ".repeat(Math.min(lead + hang, half));
102
+ const first = lines.length;
103
+ let start = " ".repeat(lead);
104
+ let line = start;
105
+ const push = () => { lines.push(sanitize(line.trimEnd())); line = start = pad; };
106
+ for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
107
+ if (width(line + token) <= columns) { line += token; continue; }
108
+ if (line !== start) push();
109
+ if (/^\s/.test(token)) continue;
110
+ for (const char of token) {
111
+ if (line !== start && width(line + char) > columns) push();
112
+ line += char;
113
+ }
83
114
  }
84
- lines.push(line);
115
+ if (line !== start || lines.length === first) lines.push(sanitize(line.trimEnd()));
116
+ }
117
+ return lines;
118
+ }
119
+ export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
120
+ /** `45s`, `12m`, `3h05m`, `2d03h`. */
121
+ export function duration(ms: number): string {
122
+ const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
123
+ 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`;
124
+ }
125
+ /** A moment as `in 2h13m` or `12m ago`. */
126
+ export const relative = (at: number, now: number) => at > now ? `in ${duration(at - now)}` : `${duration(now - at)} ago`;
127
+ const WORD = /^\p{L}[\p{L}\p{N}_./:@#+-]*$/u; // a letter first: `1 2` must not read as two numbers
128
+ /**
129
+ * An agent-written string inside hub-built text (a detail below its field, an option name in a prompt): a plain word as
130
+ * it is, anything else JSON-quoted, so no `;`, `,`, `)`, quote or newline in it can end it and pass for hub structure.
131
+ */
132
+ export const quoted = (text: unknown) => { const value = String(text ?? ""); return WORD.test(value) ? value : JSON.stringify(value); };
133
+ const TIME_KEY = /^(?:at|created|updated|since|expires)$|At$/;
134
+ /**
135
+ * A detail value as text, never JSON: times relative, shares as percent, objects as `key value` pairs, nested lists
136
+ * inline. Only a field's own string value shows as written; every string below it goes through `quoted`.
137
+ */
138
+ function detailText(value: unknown, key: string, now: number, nested = false): string {
139
+ if (value === null || value === undefined || (value === "" && !nested)) return "-";
140
+ if (typeof value === "number" && TIME_KEY.test(key)) return relative(value, now);
141
+ if (typeof value === "number" && key === "used") return `${Math.round(value * 100)}%`;
142
+ 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(", ") || "-";
143
+ if (typeof value === "object") return Object.entries(value).map(([k, v]) => `${k} ${detailText(v, k, now, true)}`).join("; ") || "-";
144
+ return nested ? quoted(value) : String(value);
145
+ }
146
+ /**
147
+ * Labels at column 0, values one column past the longest label. A list puts the hub's `- ` before each item, and every
148
+ * other line of a value (its own newlines, wrapped continuations) starts two columns deeper: untrusted text can pass
149
+ * neither for a label nor for an item. Allow options keep one line each, cut rather than wrapped.
150
+ */
151
+ function fieldLines(fields: [string, unknown][], columns: number, now: number): Span[][][] {
152
+ const pad = Math.min(16, Math.max(0, ...fields.map(([label]) => Bun.stringWidth(label))) + 2);
153
+ const width = columns - pad - 2;
154
+ return fields.map(([label, value]) => {
155
+ const list = Array.isArray(value) && value.length > 0;
156
+ const items = list ? (value as unknown[]).map(item => detailText(item, label, now, true)) : [detailText(value, label, now)];
157
+ const lines = items.flatMap(item => (label === "a allow" ? [fit(item, width)] : wrap(item, width)).map((text, i) => (i ? " " : list ? "- " : "") + text));
158
+ return lines.map((text, i) => {
159
+ const head = i ? "" : fit(label, pad - 2);
160
+ return [span(head + " ".repeat(pad - Bun.stringWidth(head)), i ? undefined : "info"), span(text)];
161
+ });
162
+ });
163
+ }
164
+ const detailLines = (detail: Detail, columns: number, now: number): Span[][] =>
165
+ typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(Object.entries(detail), columns, now).flat();
166
+ const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
167
+ /** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
168
+ export function approvalFrom(msg: any): Approval {
169
+ return { id: String(msg.id ?? ""), peer: String(msg.peer ?? ""), title: String(msg.title ?? ""), expiresAt: Number(msg.expiresAt),
170
+ options: (Array.isArray(msg.options) ? msg.options : []).map((o: any) => ({ optionId: String(o?.optionId ?? ""), name: String(o?.name ?? ""), kind: String(o?.kind ?? "") })) };
171
+ }
172
+ /** Errors and refusals take the failure tone; a notice that only informs passes `attention`. */
173
+ export function notify(s: ConsoleState, text: string, now: number, tone: Tone = "failure"): void {
174
+ s.notice = text; s.noticeAt = now; s.noticeTone = tone;
175
+ }
176
+ /** 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. */
177
+ function approvalFields(a: Approval): [string, unknown][] {
178
+ const allow = allowOptions(a);
179
+ return [["request", a.id], ["peer", a.peer], ["expires", a.expiresAt], ["title", a.title],
180
+ ["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"]];
181
+ }
182
+ /** How the stream shows a permission request: hub text at columns 2 and 4, title lines framed at column 6. */
183
+ export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
184
+ 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`;
185
+ }
186
+ const KEYS: [string, string[]][] = [
187
+ ["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
188
+ ["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
189
+ ["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
190
+ ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
191
+ ["Peers", ["p pause", "r resume"]],
192
+ ["Tasks", ["a assign (then y)", "r review"]],
193
+ ["Queue", ["r resolve (reason, then y)"]],
194
+ ["Events", ["f peer filter", "g kind filter"]],
195
+ ];
196
+ /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
197
+ export function keyTable(columns: number): Span[][] {
198
+ const lines: Span[][] = []; const pad = 12;
199
+ for (const [group, items] of KEYS) {
200
+ let text = ""; let head = group;
201
+ const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
202
+ for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
203
+ flush();
85
204
  }
86
205
  return lines;
87
206
  }
88
207
  export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
89
208
  const approvals = s.approvals.filter(a => a.expiresAt > now);
90
209
  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 } : {}) };
210
+ const gone = s.approvalId !== undefined && !live(s.approvalId);
211
+ return bound({ ...s, approvals,
212
+ ...(gone ? { approvalId: undefined, notice: `request ${s.approvalId} closed${approvals.length ? "; [ ] selects another" : ""}`, noticeAt: now, noticeTone: "attention" as const } : {}) });
213
+ }
214
+ /**
215
+ * Everything pending on a request belongs to the selected one: a choice or confirmation for any other request is
216
+ * dropped, and an open detail follows the selection from its top (it closes when nothing is selected). Every reducer
217
+ * exit and every prune passes through here, so no key can act on a request other than the one shown.
218
+ */
219
+ function bound(s: ConsoleState): ConsoleState {
220
+ if (s.optionChoice && s.optionChoice !== s.approvalId) s.optionChoice = undefined;
221
+ if (s.confirm?.type === "permission" && s.confirm.id !== s.approvalId) s.confirm = undefined;
222
+ if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
223
+ return s;
94
224
  }
95
225
  const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
96
226
  /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
@@ -123,21 +253,27 @@ export function panelRows(s: ConsoleState): any[] {
123
253
  if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
124
254
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
125
255
  }
256
+ export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
257
+ /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
258
+ const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
126
259
  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 });
260
+ // A notice answers the key before this one; one that pruning raises now stays.
261
+ let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
262
+ const done = () => ({ state: bound(s), effects });
263
+ const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
129
264
  if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
130
265
  if (s.confirm) {
131
266
  const confirm = s.confirm; s.confirm = undefined;
132
267
  if (key === "y") {
133
268
  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")))) {
269
+ else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
135
270
  effects.push({ type: "permit", id: confirm.id, option: confirm.option });
136
- s.approvals = s.approvals.filter(a => a.id !== confirm.id);
271
+ answered(confirm.id);
137
272
  }
138
273
  }
139
274
  return done();
140
275
  }
276
+ if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
141
277
  if (s.editing || s.input) {
142
278
  if (key === "\x1b") { s.input = ""; s.editing = false; }
143
279
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
@@ -157,52 +293,67 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
157
293
  s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
158
294
  if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
159
295
  else effects.push({ type: "command", args });
160
- } catch (error) { s.notice = String((error as Error).message); }
296
+ } catch (error) { notify(s, String((error as Error).message), now); }
161
297
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
162
298
  return done();
163
299
  }
164
300
  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(); }
301
+ if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
166
302
  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(); }
303
+ 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(); }
304
+ if (key === "?") {
305
+ if (s.mode === "stream") effects.push({ type: "keys" });
306
+ else s.help = !s.help;
307
+ return done();
308
+ }
309
+ if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
170
310
  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;
311
+ if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
312
+ s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
313
+ if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
174
314
  return done();
175
315
  }
176
316
  if (key === "[" || key === "]") {
177
- s.approvalIndex = (s.approvalIndex + (key === "[" ? -1 : 1) + s.approvals.length) % Math.max(1, s.approvals.length); return done();
317
+ const i = s.approvals.findIndex(a => a.id === s.approvalId), n = s.approvals.length;
318
+ if (n) s.approvalId = s.approvals[i < 0 ? (key === "]" ? 0 : n - 1) : (i + (key === "[" ? -1 : 1) + n) % n]!.id;
319
+ return done();
178
320
  }
179
- const approval = s.approvals[s.approvalIndex];
321
+ const approval = selectedApproval(s);
180
322
  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];
323
+ const a = s.approvals.find(a => a.id === s.optionChoice);
324
+ const option = a && allowOptions(a)[Number(key) - 1];
183
325
  if (/^[1-9]$/.test(key) && a && option) { s.confirm = { type: "permission", id: a.id, peer: a.peer, option: option.optionId }; s.optionChoice = undefined; }
184
326
  return done();
185
327
  }
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(); }
328
+ const requestShown = s.mode === "stream" || (s.panel === 2 && s.detail === undefined);
329
+ if (!approval && s.approvals.length && requestShown && ["a", "d", "v"].includes(key)) {
330
+ notify(s, "no request selected; [ ] selects one", now, "attention"); return done();
331
+ }
332
+ if (approval && requestShown) {
333
+ if (key === "d") { effects.push({ type: "permit", id: approval.id }); answered(approval.id); return done(); }
188
334
  if (key === "a") {
189
- const options = approval.options.filter(o => o.kind.startsWith("allow"));
335
+ const options = allowOptions(approval);
190
336
  if (options.length === 1) s.confirm = { type: "permission", id: approval.id, peer: approval.peer, option: options[0]!.optionId };
191
337
  else if (options.length) s.optionChoice = approval.id;
192
338
  return done();
193
339
  }
194
- if (key === "v") { s.detail = approval.title; s.detailOffset = 0; return done(); }
340
+ if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
341
+ if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
195
342
  }
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; }
201
- return done();
202
- }
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;
343
+ const row = panelRows(s)[selection(s)];
344
+ // With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
345
+ // a detail that is only a message names no item.
346
+ const item = s.detail === undefined ? row?.id : typeof s.detail === "object" ? s.detail.id : undefined;
347
+ if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
348
+ if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
349
+ else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
350
+ else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
351
+ return done();
352
+ }
353
+ if (s.mode === "panels" && item !== undefined && item !== null) {
354
+ const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
355
+ s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
356
+ s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
206
357
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
207
358
  }
208
359
  if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
@@ -218,62 +369,134 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
218
369
  export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
219
370
  return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
220
371
  }
372
+ const NOTICE_MS = 10_000;
373
+ const TABLES: Record<number, string[]> = {
374
+ 1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
375
+ 2: ["ID", "PEER", "LEFT", "TITLE"],
376
+ 3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE"],
377
+ 4: ["ID", "PEER", "STATE", "REV", "AGE"],
378
+ };
379
+ const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
380
+ /** 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[] {
382
+ const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
383
+ if (s.panel === 1) {
384
+ const b = s.budget[row.id];
385
+ const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
386
+ ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
387
+ : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
388
+ return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.state, stateTone(row.state)),
389
+ span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
390
+ span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
391
+ span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
392
+ 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(", ") : "-"),
393
+ span(row.servedBy ?? row.requestedModel ?? "-")];
394
+ }
395
+ if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
396
+ if (s.panel === 3) {
397
+ const last = row.history?.at(-1);
398
+ const at = last?.at ?? row.updated ?? row.created;
399
+ 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)];
402
+ }
403
+ 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
+ const [header, ...body] = String(row.text).split("\n");
405
+ return [span(header, row.tone), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
406
+ }
407
+ /**
408
+ * A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
409
+ * widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
410
+ * takes the rest.
411
+ */
412
+ function table(head: string[], rows: Span[][], columns: number): Span[][] {
413
+ const cap = Math.max(24, Math.floor(columns / 3));
414
+ const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
415
+ const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
416
+ return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
417
+ if (i === head.length - 1) return cell;
418
+ const text = fit(cell.text, widths[i]!);
419
+ return { ...cell, text: text + " ".repeat(widths[i]! - Bun.stringWidth(text) + 2) };
420
+ }));
421
+ }
422
+ /** The footer's key hint: only keys that act in this mode, panel and state. */
423
+ function hint(s: ConsoleState): string {
424
+ const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
425
+ 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
+ if (s.help) return "? or Esc close q quit";
427
+ const approvals = s.panel === 2 ? pending : [];
428
+ if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
429
+ 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(" ");
432
+ }
433
+ /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
434
+ function header(s: ConsoleState, columns: number): Span[] {
435
+ const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
436
+ const tabs: Span[] = [];
437
+ for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events"].entries()) {
438
+ const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
439
+ tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
440
+ if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
441
+ tabs.push(span(active ? "]" : " ", tone));
442
+ }
443
+ return fitLine([span(fit(s.project ?? "agent-hub", Math.max(0, columns - Bun.stringWidth(tabs.map(t => t.text).join("")))), "info"), ...tabs], columns);
444
+ }
445
+ /** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
446
+ const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
221
447
  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`;
448
+ const permission = selectedApproval(s);
449
+ let prompt = s.editing ? `: ${s.input}` : hint(s);
450
+ if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
225
451
  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(" | ") ?? "";
452
+ const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
453
+ if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
227
454
  const summary: Span[] = [];
228
455
  for (const [id, p] of Object.entries(s.peers)) {
229
456
  if (summary.length) summary.push(span(" "));
230
457
  summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
231
458
  if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
232
459
  if (p.paused) summary.push(span(" paused", "attention"));
460
+ if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
233
461
  }
234
462
  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
463
  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}`));
464
+ const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
465
+ if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
466
+ 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"));
239
468
  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)])); }
469
+ const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
470
+ if (s.mode === "stream") return [rule, ...footer];
471
+ const height = rows - 6; // header, rule, body, rule, three footer lines
472
+ const lines: Span[][] = [];
473
+ if (s.help) lines.push(...keyTable(columns));
474
+ else if (s.detail !== undefined || (s.requestDetail && permission)) {
475
+ const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
476
+ lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
477
+ }
249
478
  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));
479
+ const data = panelRows(s); const head = TABLES[s.panel]; const at = selection(s);
480
+ // Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
481
+ // shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
482
+ const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
483
+ const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
484
+ if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
485
+ 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;
488
+ lines.push(fitLine([span(" "), ...(rowsOf?.[0] ?? [span(`peer ${s.peerFilter ?? "all"} kind ${s.kindFilter ?? "all"}`, "info")])], columns));
489
+ for (let i = start; i < Math.min(data.length, start + capacity); i++) {
490
+ const selected = i === at;
491
+ lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
492
+ }
493
+ if (!data.length) lines.push([span(" (empty)")]);
494
+ if (title.length) {
495
+ const room = Math.max(1, height - lines.length - 1 - tail.length);
496
+ if (title.length > room) { title.length = room; title[room - 1] = more(title[room - 1]!); }
497
+ lines.push(rule, ...title, ...tail);
273
498
  }
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
499
  }
277
- const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push([]);
278
- return [...body, ...footer];
500
+ const body = lines.slice(0, height); while (body.length < height) body.push([]);
501
+ return [header(s, columns), rule, ...body, rule, ...footer];
279
502
  }