@staix/agent-hub 0.12.16 → 0.12.18

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +2 -0
  5. package/docs/agent-notes/tasks.md +3 -1
  6. package/docs/agent-notes/tests.md +1 -0
  7. package/docs/events.md +37 -3
  8. package/docs/operations.md +149 -10
  9. package/docs/quickstart.md +3 -3
  10. package/docs/security.md +28 -1
  11. package/docs/smoke.md +93 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +176 -1
  13. package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
  14. package/docs/verified.json +26 -17
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +17 -5
  18. package/src/adapters/acp.ts +2 -2
  19. package/src/adapters/claude-channel.ts +3 -2
  20. package/src/adapters/codex-appserver.ts +3 -2
  21. package/src/adapters/local-worker.ts +16 -12
  22. package/src/cli/console-state.ts +279 -0
  23. package/src/cli/console.ts +224 -0
  24. package/src/cli/facts-hook.ts +8 -1
  25. package/src/cli/identity-audit.ts +66 -0
  26. package/src/cli/identity.ts +54 -0
  27. package/src/cli/launch.ts +15 -2
  28. package/src/cli/main.ts +75 -32
  29. package/src/cli/tail-render.ts +17 -0
  30. package/src/cli/upgrade-runtime.ts +1 -1
  31. package/src/hub/attribution.ts +20 -0
  32. package/src/hub/board.ts +6 -2
  33. package/src/hub/bus.ts +88 -10
  34. package/src/hub/child-process.ts +11 -0
  35. package/src/hub/conductor.ts +196 -0
  36. package/src/hub/control-client.ts +3 -3
  37. package/src/hub/daemon.ts +388 -51
  38. package/src/hub/envelope.ts +1 -1
  39. package/src/hub/events.ts +10 -4
  40. package/src/hub/hub-tools.ts +13 -0
  41. package/src/hub/report.ts +162 -4
  42. package/src/hub/supervision.ts +152 -0
  43. package/src/hub/tasks.ts +30 -15
  44. package/src/hub/usage.ts +37 -2
  45. package/src/pi/launch.ts +2 -1
@@ -4,7 +4,7 @@ import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.t
4
4
  import type { ToolObservation } from "../models/route/signals.ts";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
7
- import { TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
7
+ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
8
8
  import { BasePeer } from "../hub/peers.ts";
9
9
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
10
10
  import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
@@ -35,7 +35,7 @@ export interface LocalOptions {
35
35
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
36
36
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
37
37
  /** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
38
- onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string }) => void;
38
+ onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string; task?: number }) => void;
39
39
  /** Atomic task/run admission immediately before every model request or tool execution. */
40
40
  admitBudget?: (envs: Envelope[], unit: "model_calls" | "tool_calls") => Promise<ExecutionBudgetDecision[]>;
41
41
  /** Per-turn policy from the task the delivery carries: the class's route, and whether it is a PII task. */
@@ -76,6 +76,7 @@ export class LocalPeer extends BasePeer {
76
76
  private readonly routes: HubRouteRuntime;
77
77
  private routeEnvs: Envelope[] = [];
78
78
  private routePii = false;
79
+ private routeTask?: number;
79
80
  private labelTurnId?: string;
80
81
  private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
81
82
  private abort: AbortController | undefined;
@@ -94,13 +95,13 @@ export class LocalPeer extends BasePeer {
94
95
  this.routes = new HubRouteRuntime({
95
96
  execute: async (model, messages, judge, signal, maxTokens) => {
96
97
  if (signal.aborted) throw new Error("turn cancelled before model request");
97
- const envs = this.routeEnvs, pii = this.routePii, generation = this.turn;
98
+ const envs = this.routeEnvs, pii = this.routePii, generation = this.turn, task = this.routeTask;
98
99
  await this.requireBudget(envs, "model_calls");
99
100
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
100
101
  if (signal.aborted) throw new Error("turn cancelled before model request");
101
- const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
102
+ const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
102
103
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
103
- this.recordUsage(result, model);
104
+ this.recordUsage(result, model, task);
104
105
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
105
106
  this.lastServedBy = `hub ${model} (provider ${result.provider ?? "?"})`;
106
107
  return result;
@@ -250,7 +251,7 @@ export class LocalPeer extends BasePeer {
250
251
  await this.requireBudget(envs, "tool_calls");
251
252
  if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
252
253
  } catch (error) { clearInterval(alive); throw error; }
253
- const running = TASK_TOOL_NAMES.has(name) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
254
+ const running = (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
254
255
  const output = await running.finally(() => clearInterval(alive));
255
256
  if (turn !== this.turn) return "";
256
257
  this.touch();
@@ -281,7 +282,7 @@ export class LocalPeer extends BasePeer {
281
282
  });
282
283
  },
283
284
  sandboxProfile: this.sandboxProfile,
284
- sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
285
+ sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },
285
286
  signal: turnSignal,
286
287
  send: (text, to) => {
287
288
  const refused = this.onMessage?.(text, pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
@@ -329,12 +330,13 @@ export class LocalPeer extends BasePeer {
329
330
  }
330
331
 
331
332
  /** L2 when the sidecar is up, otherwise (or when a call through it fails) the fixed model on L3. */
332
- private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean } | undefined, envs: Envelope[]): Promise<ChatResult> {
333
+ private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
333
334
  const { omni, sidecar } = this.opts;
334
335
  // A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
335
336
  const route = policy?.route ?? this.opts.route;
336
337
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
337
- const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
338
+ const task = policy?.task === undefined ? undefined : Number(policy.task);
339
+ const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
338
340
  const signal = this.abort!.signal;
339
341
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
340
342
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -345,7 +347,7 @@ export class LocalPeer extends BasePeer {
345
347
  try {
346
348
  const res = await omni.chat({ model: route!, messages, tools }, { via, sessionId: this.sessionId, signal });
347
349
  this.lastServedBy = `switchyard ${route} -> ${res.selectedModel ?? "?"}`;
348
- this.recordUsage(res, route!);
350
+ this.recordUsage(res, route!, task);
349
351
  return res;
350
352
  } catch (e) {
351
353
  if (signal.aborted) throw e;
@@ -355,7 +357,7 @@ export class LocalPeer extends BasePeer {
355
357
  await this.requireBudget(envs, "model_calls");
356
358
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
357
359
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
358
- this.recordUsage(res, fixedModel);
360
+ this.recordUsage(res, fixedModel, task);
359
361
  return res;
360
362
  }
361
363
 
@@ -364,6 +366,7 @@ export class LocalPeer extends BasePeer {
364
366
  try { id = this.opts.turnId?.(); } catch { /* optional host metadata */ }
365
367
  id ??= `${this.id}#${this.sessionId}.${generation}`;
366
368
  const number = task ? Number(task) : undefined;
369
+ this.routeTask = Number.isSafeInteger(number) && number! > 0 ? number : undefined;
367
370
  this.labelTurnId = id;
368
371
  this.routes.beginTurn({ turn: id, pii, ...(Number.isSafeInteger(number) && number! > 0 ? { task: number } : {}) });
369
372
  return id;
@@ -418,13 +421,14 @@ export class LocalPeer extends BasePeer {
418
421
  } catch { /* optional research observations */ }
419
422
  }
420
423
 
421
- private recordUsage(res: ChatResult, requestedModel: string): void {
424
+ private recordUsage(res: ChatResult, requestedModel: string, task?: number): void {
422
425
  try {
423
426
  this.opts.onUsage?.({
424
427
  id: randomUUID(),
425
428
  at: new Date().toISOString(),
426
429
  ...(res.usage ? { usage: res.usage } : {}),
427
430
  requestedModel: safeModelLabel(requestedModel) ?? "unknown",
431
+ ...(Number.isSafeInteger(task) && task! > 0 ? { task } : {}),
428
432
  ...(safeModelLabel(res.servedModel ?? res.selectedModel) ? { servedModel: safeModelLabel(res.servedModel ?? res.selectedModel)! } : {}),
429
433
  ...(safeModelLabel(res.provider) ? { provider: safeModelLabel(res.provider)! } : {}),
430
434
  });
@@ -0,0 +1,279 @@
1
+ import { sanitize } from "../hub/envelope.ts";
2
+
3
+ export interface Approval {
4
+ id: string; peer: string; title: string; expiresAt: number;
5
+ options: { optionId: string; name: string; kind: string }[];
6
+ }
7
+ export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
8
+ export interface ConsoleState {
9
+ mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
10
+ input: string; editing: boolean; history: string[]; historyIndex: number;
11
+ 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[] };
15
+ optionChoice?: string;
16
+ }
17
+ export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
18
+ { type: "command"; args: string[] } | { type: "show"; panel: number; id: string };
19
+ 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: "" };
22
+ }
23
+ /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
24
+ export function terminalText(value: unknown): string {
25
+ return sanitize(String(value ?? "")
26
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
27
+ .replace(/\x1b[P_X^][\s\S]*?(?:\x1b\\|$)/g, "")
28
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
29
+ .replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
30
+ }
31
+ export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted";
32
+ export interface Span { text: string; tone?: Tone }
33
+ export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
34
+ info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
35
+ });
36
+ export function paint(line: Span[], color: boolean): string {
37
+ return line.map(span => {
38
+ const text = terminalText(span.text);
39
+ const sgr = span.tone && Object.hasOwn(PALETTE, span.tone) ? PALETTE[span.tone] : undefined;
40
+ return color && sgr && text ? sgr + text + "\x1b[0m" : text;
41
+ }).join("");
42
+ }
43
+ export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
44
+ if (flag === "always") return true;
45
+ if (flag === "never") return false;
46
+ if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
47
+ return new Error("usage: ahub console [--panels] [--color=auto|always|never]");
48
+ }
49
+ export function stateTone(state: string): Tone | undefined {
50
+ if (state === "idle" || state === "approved") return "success";
51
+ if (["busy", "paused", "in_review", "changes_requested", "ready"].includes(state)) return "attention";
52
+ if (["failed", "check failed", "needs_review"].includes(state)) return "failure";
53
+ return undefined;
54
+ }
55
+ 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. */
57
+ 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; }
65
+ if (marker) out.push(span(marker));
66
+ return out;
67
+ }
68
+ 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[] {
77
+ 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;
83
+ }
84
+ lines.push(line);
85
+ }
86
+ return lines;
87
+ }
88
+ export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
89
+ const approvals = s.approvals.filter(a => a.expiresAt > now);
90
+ 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 } : {}) };
94
+ }
95
+ const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
96
+ /** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
97
+ export function parseConsoleCommand(input: string): string[] {
98
+ const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
99
+ for (const char of input.trim()) {
100
+ if (escaped) { word += char; escaped = false; started = true; }
101
+ else if (char === "\\") { escaped = true; started = true; }
102
+ else if (quote) { if (char === quote) quote = ""; else word += char; }
103
+ else if (char === '"' || char === "'") { quote = char; started = true; }
104
+ else if (/\s/.test(char)) { if (started) { words.push(word); word = ""; started = false; } }
105
+ else { word += char; started = true; }
106
+ }
107
+ if (quote || escaped) throw new Error("unfinished quote or escape");
108
+ if (started) words.push(word);
109
+ if (!COMMANDS.has(words[0] ?? "")) throw new Error("use another shell for this command");
110
+ if (words.some(w => w === "--project" || w.startsWith("--project="))) throw new Error("console commands stay in the selected project");
111
+ return words;
112
+ }
113
+ 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");
115
+ }
116
+ export function panelRows(s: ConsoleState): any[] {
117
+ if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
118
+ if (s.panel === 2) return s.approvals;
119
+ if (s.panel === 3) {
120
+ const states = ["proposed", "in_progress", "in_review", "changes_requested"];
121
+ return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
122
+ }
123
+ if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
124
+ return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
125
+ }
126
+ 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 });
129
+ if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
130
+ if (s.confirm) {
131
+ const confirm = s.confirm; s.confirm = undefined;
132
+ 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")))) {
135
+ effects.push({ type: "permit", id: confirm.id, option: confirm.option });
136
+ s.approvals = s.approvals.filter(a => a.id !== confirm.id);
137
+ }
138
+ }
139
+ return done();
140
+ }
141
+ if (s.editing || s.input) {
142
+ if (key === "\x1b") { s.input = ""; s.editing = false; }
143
+ else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
144
+ else if (key === "\x15") s.input = "";
145
+ else if (key === "\x1b[A" || key === "\x1b[B") {
146
+ s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
147
+ s.input = s.history[s.historyIndex] ?? "";
148
+ } else if (key === "\r" || key === "\n") {
149
+ const input = s.input; s.input = ""; s.editing = false;
150
+ if (!input.trim()) return done();
151
+ try {
152
+ const args = parseConsoleCommand(input);
153
+ if (args[0] === "queue" && args[1] === "resolve") {
154
+ const reason = args.indexOf("--reason");
155
+ if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
156
+ }
157
+ s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
158
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
159
+ else effects.push({ type: "command", args });
160
+ } catch (error) { s.notice = String((error as Error).message); }
161
+ } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
162
+ return done();
163
+ }
164
+ 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(); }
166
+ 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(); }
170
+ 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;
174
+ return done();
175
+ }
176
+ if (key === "[" || key === "]") {
177
+ s.approvalIndex = (s.approvalIndex + (key === "[" ? -1 : 1) + s.approvals.length) % Math.max(1, s.approvals.length); return done();
178
+ }
179
+ const approval = s.approvals[s.approvalIndex];
180
+ 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];
183
+ if (/^[1-9]$/.test(key) && a && option) { s.confirm = { type: "permission", id: a.id, peer: a.peer, option: option.optionId }; s.optionChoice = undefined; }
184
+ return done();
185
+ }
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(); }
188
+ if (key === "a") {
189
+ const options = approval.options.filter(o => o.kind.startsWith("allow"));
190
+ if (options.length === 1) s.confirm = { type: "permission", id: approval.id, peer: approval.peer, option: options[0]!.optionId };
191
+ else if (options.length) s.optionChoice = approval.id;
192
+ return done();
193
+ }
194
+ if (key === "v") { s.detail = approval.title; s.detailOffset = 0; return done(); }
195
+ }
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;
206
+ if (prefill) { s.input = prefill; s.editing = true; return done(); }
207
+ }
208
+ if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
209
+ const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
210
+ const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
211
+ const next = values[values.indexOf(current ?? "") + 1];
212
+ if (key === "f") s.peerFilter = next; else s.kindFilter = next;
213
+ s.selection = 0; return done();
214
+ }
215
+ if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
216
+ return done();
217
+ }
218
+ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
219
+ return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
220
+ }
221
+ 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`;
225
+ 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(" | ") ?? "";
227
+ const summary: Span[] = [];
228
+ for (const [id, p] of Object.entries(s.peers)) {
229
+ if (summary.length) summary.push(span(" "));
230
+ summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
231
+ if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
232
+ if (p.paused) summary.push(span(" paused", "attention"));
233
+ }
234
+ 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)])); }
249
+ 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));
273
+ }
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
+ }
277
+ const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push([]);
278
+ return [...body, ...footer];
279
+ }
@@ -0,0 +1,224 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import type { ControlClient } from "../hub/control-client.ts";
4
+ import { contextLine } from "./status-lines.ts";
5
+ import { renderTailEvent } from "./tail-render.ts";
6
+ import { initialConsoleState, paint, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, terminalText, wrap } from "./console-state.ts";
7
+ import type { ConsoleEffect, ConsoleEvent, Tone } from "./console-state.ts";
8
+ import type { BusEvent } from "../hub/bus.ts";
9
+
10
+ export interface ConsoleTerminal {
11
+ columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
12
+ write(text: string): void;
13
+ raw(enabled: boolean): void;
14
+ onData(listener: (text: string) => void): () => void;
15
+ onResize(listener: () => void): () => void;
16
+ onSignal(listener: () => void): () => void;
17
+ onError?(listener: (error: Error) => void): () => void;
18
+ }
19
+ export interface ConsoleOptions {
20
+ client: Pick<ControlClient, "request" | "send" | "close" | "onPush" | "onClose">;
21
+ cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
22
+ terminal?: ConsoleTerminal;
23
+ color?: boolean;
24
+ /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
25
+ runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
26
+ pollMs?: number;
27
+ }
28
+ export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
29
+ /** Console-only styling uses event structure; tail text and message bodies are never parsed for meaning. */
30
+ export function eventTone(e: BusEvent): Tone | undefined {
31
+ if (e.t === "state") return stateTone(e.state);
32
+ if (e.t === "undeliverable" || e.t === "overflow") return "failure";
33
+ if (e.t === "envelope") {
34
+ if (e.dropped === "hop") return "failure";
35
+ if (e.env.priority === "important" || e.env.kind === "task" || e.env.kind === "review" || e.env.kind === "budget") return "attention";
36
+ return "info";
37
+ }
38
+ return undefined;
39
+ }
40
+ function nativeTerminal(): ConsoleTerminal {
41
+ return {
42
+ get columns() { return process.stdout.columns ?? 80; }, get rows() { return process.stdout.rows ?? 24; },
43
+ get isTTY() { return !!process.stdin.isTTY && !!process.stdout.isTTY; }, get isRaw() { return process.stdin.isRaw; },
44
+ write: text => { process.stdout.write(text); }, raw: enabled => { process.stdin.setRawMode(enabled); },
45
+ onData: listener => {
46
+ const decoder = new StringDecoder("utf8"); const onData = (data: Buffer) => listener(decoder.write(data));
47
+ process.stdin.on("data", onData); process.stdin.resume();
48
+ return () => { process.stdin.off("data", onData); process.stdin.pause(); };
49
+ },
50
+ onResize: listener => { process.stdout.on("resize", listener); return () => { process.stdout.off("resize", listener); }; },
51
+ onError: listener => {
52
+ const rejected = (reason: unknown) => listener(reason instanceof Error ? reason : new Error(String(reason)));
53
+ process.on("uncaughtException", listener); process.on("unhandledRejection", rejected);
54
+ return () => { process.off("uncaughtException", listener); process.off("unhandledRejection", rejected); };
55
+ },
56
+ onSignal: listener => { process.on("SIGINT", listener); process.on("SIGTERM", listener); return () => { process.off("SIGINT", listener); process.off("SIGTERM", listener); }; },
57
+ };
58
+ }
59
+ /** One terminal client, no daemon ownership. All text crosses terminalText before writing. */
60
+ export async function runConsole(options: ConsoleOptions): Promise<void> {
61
+ const { client } = options; const terminal = options.terminal ?? nativeTerminal();
62
+ const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
63
+ let state = initialConsoleState(!!options.panels);
64
+ let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
65
+ let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
66
+ let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
67
+ const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
68
+ const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
69
+ let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
70
+ let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
71
+ const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
72
+ const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => wrap(text, columns).map(line => [{ text: line, ...(index === 0 && event.tone ? { tone: event.tone } : {}) }]));
73
+ const writeStream = (event: ConsoleEvent) => {
74
+ terminal.write(`\x1b[${rows - 3};1H`);
75
+ for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
76
+ };
77
+ const stream = (event: ConsoleEvent) => {
78
+ state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
79
+ if (plain) {
80
+ const [header, ...body] = terminalText(event.text).split("\n");
81
+ terminal.write(paint([{ text: header ?? "", tone: event.tone }, ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
82
+ }
83
+ else if (state.mode === "stream") writeStream(event);
84
+ else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
85
+ };
86
+ const draw = () => {
87
+ if (!active || plain) return;
88
+ if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
89
+ else if (state.mode === "stream" && inAlternate) {
90
+ terminal.write(`\x1b[?1049l\x1b[1;${rows - 3}r`); inAlternate = false;
91
+ if (droppedStream) writeStream({ text: `${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.` });
92
+ for (const event of pendingStream) writeStream(event);
93
+ pendingStream = []; droppedStream = 0;
94
+ }
95
+ terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 3}r` : "\x1b[r");
96
+ const lines = renderConsoleLines(state, columns, rows);
97
+ const start = state.mode === "stream" ? rows - 2 : 1;
98
+ for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
99
+ terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(paint(lines.at(-1) ?? [], false)) + 1)}H\x1b[?25h`);
100
+ };
101
+ const stop = (reason?: string) => {
102
+ if (!active) return;
103
+ active = false;
104
+ for (const timer of timers) clearInterval(timer);
105
+ for (const remove of removers) remove();
106
+ cancelChild?.(); cancelChild = undefined;
107
+ client.onPush = priorPush; client.onClose = priorClose;
108
+ if (terminal.isTTY) {
109
+ try { terminal.raw(priorRaw); } finally { terminal.write(RESTORE_CONSOLE + "\r\n"); }
110
+ }
111
+ client.close();
112
+ safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
113
+ resolveDone();
114
+ };
115
+ const refresh = async () => {
116
+ if (polling || !active) return; polling = true;
117
+ try {
118
+ const panels = state.mode === "panels" && !plain;
119
+ const replies = await Promise.all([
120
+ client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
121
+ ...(panels ? [client.request({ t: "task", op: "hub_task_list", args: {} }, 3000), client.request({ t: "queue", op: "list" }, 3000), client.request({ t: "task", op: "hub_task_list", args: { ready: true } }, 3000)] : []),
122
+ ]);
123
+ if (!active) return;
124
+ const [status, budget, tasks, queue, ready] = replies;
125
+ if (status.status?.peers) state.peers = status.status.peers;
126
+ if (budget.budget) state.budget = budget.budget;
127
+ if (tasks?.ok && state.mode === "panels") { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
128
+ const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
129
+ state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
130
+ } }
131
+ if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
132
+ const error = replies.find(reply => reply.ok === false)?.error; if (error) state.notice = String(error);
133
+ draw();
134
+ } catch (error) { if (active) { state.notice = String((error as Error).message); draw(); } }
135
+ finally { polling = false; }
136
+ };
137
+ const run = options.runCommand ?? ((args, output, finished) => {
138
+ const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
139
+ cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
140
+ });
141
+ const read = async (source: ReadableStream<Uint8Array>) => {
142
+ const decoder = new TextDecoder(); const reader = source.getReader();
143
+ try { while (true) { const { value, done } = await reader.read(); if (done) break; output(decoder.decode(value, { stream: true })); } const end = decoder.decode(); if (end) output(end); }
144
+ finally { reader.releaseLock(); }
145
+ };
146
+ void Promise.all([read(child.stdout), read(child.stderr), child.exited]).then(([, , code]) => { if (code) output(`command exited ${code}`); }).catch(error => output(String(error.message))).finally(finished);
147
+ return () => { child.kill(); };
148
+ });
149
+ const effect = async (action: ConsoleEffect) => {
150
+ if (action.type === "exit") return stop();
151
+ if (action.type === "permit") {
152
+ client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
153
+ if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
154
+ return;
155
+ }
156
+ if (action.type === "show") {
157
+ const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
158
+ if (!active) return;
159
+ state.detailOffset = 0;
160
+ state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? String(result.text) : JSON.stringify(result.delivery, null, 2);
161
+ draw(); return;
162
+ }
163
+ if (childRunning) { state.notice = "a command is already running"; draw(); return; }
164
+ childRunning = true;
165
+ stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
166
+ try {
167
+ cancelChild = run(action.args, text => { if (active) { stream({ text, kind: "command" }); draw(); } }, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
168
+ } catch (error) { childRunning = false; state.notice = String((error as Error).message); draw(); }
169
+ };
170
+ const handle = (key: string) => {
171
+ if (!active) return;
172
+ const result = reduceConsole(state, key); state = result.state;
173
+ if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; state.notice = "panels need at least 80x24"; }
174
+ for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
175
+ draw();
176
+ if (key === "v" && state.detail && state.mode === "stream") { stream({ text: state.detail, kind: "permission" }); state.detail = undefined; draw(); }
177
+ if (key === "\t") void refresh();
178
+ };
179
+ client.onPush = msg => {
180
+ if (!active) return;
181
+ try {
182
+ if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
183
+ else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading)}`, peer: msg.peer, kind: "context" });
184
+ else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
185
+ else if (msg.t === "permission") {
186
+ stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission", tone: "attention" });
187
+ if (typeof msg.expiresAt === "number" && msg.expiresAt > Date.now() && !state.approvals.some(a => a.id === msg.id)) state.approvals.push(msg);
188
+ } else if (msg.t === "permission_closed") {
189
+ state.approvals = state.approvals.filter(a => a.id !== msg.id); state = pruneApprovals(state, Date.now());
190
+ if (msg.reason === "expired" || msg.outcome === "cancelled" || msg.outcome?.startsWith("reject")) stream({ text: ` ! ${msg.peer} permission ${msg.id} ${msg.reason ?? msg.outcome}`, peer: msg.peer, kind: "permission", tone: "failure" });
191
+ }
192
+ draw();
193
+ } catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
194
+ };
195
+ client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
196
+ try {
197
+ if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
198
+ if (terminal.isTTY) {
199
+ terminal.raw(true);
200
+ removers.push(terminal.onData(text => {
201
+ // A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
202
+ if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
203
+ state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
204
+ state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
205
+ } else handle(text);
206
+ }));
207
+ removers.push(terminal.onResize(() => {
208
+ columns = terminal.columns; rows = terminal.rows; plain = columns < 80 || rows < 24;
209
+ if (plain) {
210
+ if (inAlternate) { terminal.write("\x1b[?1049l"); inAlternate = false; }
211
+ terminal.write("\x1b[r"); state.mode = "stream"; state.notice = "panels need at least 80x24";
212
+ safeWrite("\nTerminal below 80x24; showing the plain stream.\n");
213
+ }
214
+ draw();
215
+ }));
216
+ }
217
+ removers.push(terminal.onSignal(() => stop()));
218
+ if (terminal.onError) removers.push(terminal.onError(error => { process.exitCode = 1; stop(`Console error: ${error.message}`); }));
219
+ timers.push(setInterval(() => { try { state = pruneApprovals(state, Date.now()); draw(); } catch (error) { stop(`Console error: ${String((error as Error).message)}`); } }, 1000));
220
+ timers.push(setInterval(() => void refresh(), Math.max(2000, options.pollMs ?? 4000)));
221
+ draw(); client.send({ t: "tail" }); void refresh(); await done;
222
+ } catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
223
+ finally { stop(); }
224
+ }