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