@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.
- package/CHANGELOG.md +14 -0
- package/README.md +4 -4
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/events.md +37 -3
- package/docs/operations.md +149 -10
- package/docs/quickstart.md +3 -3
- package/docs/security.md +28 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +176 -1
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +17 -5
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +3 -2
- package/src/adapters/local-worker.ts +16 -12
- package/src/cli/console-state.ts +279 -0
- package/src/cli/console.ts +224 -0
- package/src/cli/facts-hook.ts +8 -1
- package/src/cli/identity-audit.ts +66 -0
- package/src/cli/identity.ts +54 -0
- package/src/cli/launch.ts +15 -2
- package/src/cli/main.ts +75 -32
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/attribution.ts +20 -0
- package/src/hub/board.ts +6 -2
- package/src/hub/bus.ts +88 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +388 -51
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +10 -4
- package/src/hub/hub-tools.ts +13 -0
- package/src/hub/report.ts +162 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/tasks.ts +30 -15
- package/src/hub/usage.ts +37 -2
- 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
|
|
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
|
+
}
|