@staix/agent-hub 0.12.45 → 0.14.0
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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +319 -34
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/cli/console-state.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { taskProgress } from "../ui/task-progress.ts";
|
|
|
7
7
|
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
8
8
|
import { sanitize } from "../hub/envelope.ts";
|
|
9
9
|
import type { SettingRow, SettingValue } from "../hub/settings.ts";
|
|
10
|
+
import { symbolFields, overlapFields } from "./console-symbols.ts";
|
|
11
|
+
import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
|
|
12
|
+
import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
|
|
10
13
|
|
|
11
14
|
export interface Approval {
|
|
12
15
|
id: string; peer: string; title: string; expiresAt: number;
|
|
@@ -45,10 +48,16 @@ export interface ConsoleState {
|
|
|
45
48
|
tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
46
49
|
peerFilter?: string; kindFilter?: string; project?: string;
|
|
47
50
|
taskSelection?: number; queueSelection?: string;
|
|
51
|
+
/** #683 U3: the Usage panel (7): what the `usage` op last answered, and the task that was selected in Tasks when the panel was opened. */
|
|
52
|
+
usage?: UsageState; usageTask?: number;
|
|
48
53
|
actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean; syntax?: "planning" | "ownerless-return"; revisionField?: "planningRevision" | "flowRevision" };
|
|
49
54
|
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string; lock?: ConsoleState["actionLock"] } | { type: "permission_default"; peer: string; source: string };
|
|
50
55
|
optionChoice?: string;
|
|
51
56
|
modeChoice?: string;
|
|
57
|
+
/** #711: `r` on a task in review opens this choice (a approve, c request changes) the way `m` opens the permission-mode choice. */
|
|
58
|
+
reviewChoice?: ReviewChoice;
|
|
59
|
+
/** The guide line above the input after a verdict was chosen; it goes with the input line. */
|
|
60
|
+
reviewGuide?: string;
|
|
52
61
|
permissionDefaults: PermissionDefault[];
|
|
53
62
|
permissionDefaultsHandled: string[];
|
|
54
63
|
}
|
|
@@ -84,7 +93,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
|
|
|
84
93
|
s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
|
|
85
94
|
const pending = s.confirm;
|
|
86
95
|
if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
|
|
87
|
-
if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
|
|
96
|
+
if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.reviewChoice || s.optionChoice || s.help) return false;
|
|
88
97
|
const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
|
|
89
98
|
if (!next) return false;
|
|
90
99
|
s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
|
|
@@ -310,8 +319,19 @@ function fieldLines(fields: [string, unknown][], columns: number, now: number):
|
|
|
310
319
|
});
|
|
311
320
|
});
|
|
312
321
|
}
|
|
313
|
-
|
|
314
|
-
|
|
322
|
+
/**
|
|
323
|
+
* A task opened from Tasks also gets its "changed symbols" (#684 2b), after its plan so the two read together; a private task gets none. `codeSymbols` is the hub's
|
|
324
|
+
* raw observation and `symbolOverlaps` is the hub pair projection; formatted blocks replace both raw fields.
|
|
325
|
+
*/
|
|
326
|
+
function taskFields(detail: Record<string, unknown>): [string, unknown][] {
|
|
327
|
+
const fields = Object.entries(detail).filter(([key]) => key !== "codeSymbols" && key !== "symbolOverlaps");
|
|
328
|
+
const block = [...symbolFields(detail), ...overlapFields(detail)];
|
|
329
|
+
if (!block.length) return fields;
|
|
330
|
+
const at = fields.findIndex(([key]) => key === "plan");
|
|
331
|
+
return at < 0 ? [...fields, ...block] : [...fields.slice(0, at + 1), ...block, ...fields.slice(at + 1)];
|
|
332
|
+
}
|
|
333
|
+
const detailLines = (detail: Detail, columns: number, now: number, task = false): Span[][] =>
|
|
334
|
+
typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(task ? taskFields(detail) : Object.entries(detail), columns, now).flat();
|
|
315
335
|
const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
|
|
316
336
|
/** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
|
|
317
337
|
export function approvalFrom(msg: any): Approval {
|
|
@@ -341,10 +361,11 @@ const KEYS: [string, string[]][] = [
|
|
|
341
361
|
["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Home/< first, End/> last", "Enter view", "j/k scroll a view", "w web UI (outside Peers)", "o pet window"]],
|
|
342
362
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
343
363
|
["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)"]],
|
|
344
|
-
["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r
|
|
345
|
-
["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
|
|
364
|
+
["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r approve or request changes (in review; a or c, then a note)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
|
|
365
|
+
["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry", "D discard the peer's whole queued backlog (reason, then y)"]],
|
|
346
366
|
["Events", ["f peer filter", "g kind filter"]],
|
|
347
367
|
["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
|
|
368
|
+
["Usage", ["7 Usage panel", "Enter view a peer", "tokens today/7d/lifetime with sources", "task from Tasks (3)"]],
|
|
348
369
|
];
|
|
349
370
|
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
350
371
|
export function keyTable(columns: number, attention = false, characters = false): Span[][] {
|
|
@@ -395,7 +416,7 @@ export function parseConsoleCommand(input: string): string[] {
|
|
|
395
416
|
}
|
|
396
417
|
export function commandNeedsConfirmation(args: string[]): boolean {
|
|
397
418
|
const positional = args.filter(arg => arg !== "--yes");
|
|
398
|
-
return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
|
|
419
|
+
return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && (args[1] === "resolve" || args[1] === "discard")) || (args[0] === "permit" && args[2] !== "deny");
|
|
399
420
|
}
|
|
400
421
|
export function panelRows(s: ConsoleState): any[] {
|
|
401
422
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
@@ -406,6 +427,7 @@ export function panelRows(s: ConsoleState): any[] {
|
|
|
406
427
|
return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
|
|
407
428
|
}
|
|
408
429
|
if (s.panel === 6) return [...(s.settingsRows ?? [])].sort((a, b) => a.group.localeCompare(b.group) || a.key.localeCompare(b.key));
|
|
430
|
+
if (s.panel === 7) return usagePeers(s.usage, s.budget, s.peers);
|
|
409
431
|
if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
|
|
410
432
|
return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
|
|
411
433
|
}
|
|
@@ -462,7 +484,7 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
|
|
|
462
484
|
return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
|
|
463
485
|
}
|
|
464
486
|
export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
|
|
465
|
-
const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
|
|
487
|
+
const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.reviewChoice || state.help || state.editing || state.input);
|
|
466
488
|
// A notice answers the key before this one; one that pruning raises now stays.
|
|
467
489
|
let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
|
|
468
490
|
const done = () => ({ state: bound(s), effects });
|
|
@@ -533,19 +555,47 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
533
555
|
}
|
|
534
556
|
return done();
|
|
535
557
|
}
|
|
558
|
+
if (s.reviewChoice) {
|
|
559
|
+
const choice = s.reviewChoice;
|
|
560
|
+
if (key === "\x1b") { s.reviewChoice = undefined; return done(); }
|
|
561
|
+
const verdict: Verdict | undefined = key === "a" ? "approved" : key === "c" ? "changes_requested" : undefined;
|
|
562
|
+
if (verdict) {
|
|
563
|
+
s.reviewChoice = undefined;
|
|
564
|
+
// The task may have left review while the choice was open: say so instead of prefilling a command the hub will refuse.
|
|
565
|
+
const current = s.tasks.find(task => task.id === choice.id);
|
|
566
|
+
if (current && current.state !== "in_review") notify(s, `#${choice.id} is no longer in review (${current.state})`, now);
|
|
567
|
+
else { s.input = verdictInput(choice.id, verdict); s.reviewGuide = verdictGuide(choice.id, verdict); s.editing = true; }
|
|
568
|
+
}
|
|
569
|
+
return done(); // any other key leaves the choice open
|
|
570
|
+
}
|
|
536
571
|
if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
|
|
537
572
|
if (s.editing || s.input) {
|
|
538
|
-
if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
|
|
573
|
+
if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; s.reviewGuide = undefined; }
|
|
539
574
|
else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
|
|
540
575
|
else if (key === "\x15") s.input = "";
|
|
541
576
|
else if (key === "\x1b[A" || key === "\x1b[B") {
|
|
542
577
|
s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
|
|
543
578
|
s.input = s.history[s.historyIndex] ?? "";
|
|
544
579
|
} else if (key === "\r" || key === "\n") {
|
|
545
|
-
const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
|
|
580
|
+
const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined; s.reviewGuide = undefined;
|
|
546
581
|
if (!input.trim()) return done();
|
|
547
582
|
try {
|
|
548
583
|
const parsed = parseConsoleCommand(input);
|
|
584
|
+
if (parsed[0] === "queue" && parsed[1] === "discard") {
|
|
585
|
+
const reasonAt = parsed.indexOf("--reason"), peer = commandFlag(parsed, "--peer"), state = commandFlag(parsed, "--state") ?? "queued";
|
|
586
|
+
// The reason is every word after --reason up to the next flag, so a reason typed without quotes ("agy backlog") is whole, not cut to its first word.
|
|
587
|
+
const words: string[] = [];
|
|
588
|
+
for (let i = reasonAt + 1; reasonAt >= 0 && i < parsed.length && !parsed[i]!.startsWith("--"); i++) words.push(parsed[i]!);
|
|
589
|
+
const reason = words.join(" ").trim();
|
|
590
|
+
if (!peer || !reason) throw new Error("queue discard needs --peer and --reason (it is recorded on every discarded delivery)");
|
|
591
|
+
const count = s.queue.filter(row => row.peer === peer && row.state === state).length;
|
|
592
|
+
const args = ["queue", "discard", "--peer", peer, ...(parsed.includes("--state") ? ["--state", state] : []), "--reason", reason, "--yes"];
|
|
593
|
+
s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
|
|
594
|
+
s.confirm = { type: "command", args, prompt: `discard all ${count} ${state} deliver${count === 1 ? "y" : "ies"} of ${peer}; no task or native turn is cancelled, other peers are untouched` };
|
|
595
|
+
return done();
|
|
596
|
+
}
|
|
597
|
+
const reviewProblem = reviewRefusal(parsed); // an empty changes note never reaches the hub (#711)
|
|
598
|
+
if (reviewProblem) throw new Error(reviewProblem);
|
|
549
599
|
const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
|
|
550
600
|
if (args[0] === "queue" && args[1] === "resolve") {
|
|
551
601
|
const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
|
|
@@ -614,11 +664,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
614
664
|
}
|
|
615
665
|
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
616
666
|
if (key === ":") { s.editing = true; return done(); }
|
|
617
|
-
const switchPanel = (panel: number) => {
|
|
618
|
-
|
|
667
|
+
const switchPanel = (panel: number) => {
|
|
668
|
+
// The task selected in Tasks is the one the Usage panel shows; switchPanel clears that selection, so it is read first (#683 U3).
|
|
669
|
+
// With no valid selected row (an empty Tasks panel) the old task is cleared, never kept: the panel must not show a task that is no longer the selection.
|
|
670
|
+
if (panel === 7 && s.panel === 3) { const task = selectedPanelRow(s); s.usageTask = task && Number.isSafeInteger(Number(task.id)) && Number(task.id) > 0 ? Number(task.id) : undefined; }
|
|
671
|
+
s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
672
|
+
if (s.mode === "panels" && /^[1-7]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
|
|
619
673
|
// Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
|
|
620
674
|
// digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
|
|
621
|
-
if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel
|
|
675
|
+
if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel >= 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
|
|
622
676
|
if (s.mode === "panels" && s.panel === 1 && key === "w") {
|
|
623
677
|
const peer = selectedPanelRow(s)?.id;
|
|
624
678
|
if (!peer || s.detail !== undefined) { notify(s, "select a peer row to edit its planner WIP limit", now); return done(); }
|
|
@@ -702,6 +756,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
702
756
|
if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
|
|
703
757
|
if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
|
|
704
758
|
else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
|
|
759
|
+
else if (s.panel === 7) { s.detail = { usagePeer: row.id }; s.detailOffset = 0; } // rendered live from s.usage, so a refresh updates it
|
|
705
760
|
else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
|
|
706
761
|
return done();
|
|
707
762
|
}
|
|
@@ -732,8 +787,13 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
732
787
|
else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
|
|
733
788
|
return done();
|
|
734
789
|
}
|
|
735
|
-
|
|
736
|
-
|
|
790
|
+
// #711: `r` on a task in review asks which verdict first, from the row or from the open detail, so the person is never left to guess the next word.
|
|
791
|
+
if (s.panel === 3 && key === "r" && selected.state === "in_review") {
|
|
792
|
+
const choice = reviewChoiceFor({ ...selected, reviewer: selected.reviewer ?? s.tasks.find(task => task.id === item)?.reviewer, signals: selected.signals ?? s.tasks.find(task => task.id === item)?.signals });
|
|
793
|
+
if (choice) s.reviewChoice = choice;
|
|
794
|
+
return done();
|
|
795
|
+
}
|
|
796
|
+
const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` : undefined;
|
|
737
797
|
if (prefill) { s.input = prefill; s.editing = true; return done(); }
|
|
738
798
|
if (s.panel === 3 && key === "c") {
|
|
739
799
|
if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
|
|
@@ -754,6 +814,12 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
754
814
|
s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
|
|
755
815
|
s.editing = true; return done();
|
|
756
816
|
}
|
|
817
|
+
// #713: D clears the whole queued backlog of the selected row's peer. Nothing runs until a reason is typed and y confirms the count.
|
|
818
|
+
if (s.panel === 4 && key === "D") {
|
|
819
|
+
const peer = String(selected.peer ?? "");
|
|
820
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) return done();
|
|
821
|
+
s.input = `queue discard --peer ${peer} --reason `; s.editing = true; return done();
|
|
822
|
+
}
|
|
757
823
|
if (s.panel === 4 && key === "r") {
|
|
758
824
|
if (!["queued", "needs_review"].includes(selected.state)) return done();
|
|
759
825
|
const revision = selected.revision;
|
|
@@ -787,6 +853,7 @@ export const TABLES: Record<number, string[]> = {
|
|
|
787
853
|
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
788
854
|
4: ["ID", "PEER", "STATE", "REV", "AGE"],
|
|
789
855
|
6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
|
|
856
|
+
7: USAGE_HEAD,
|
|
790
857
|
};
|
|
791
858
|
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
792
859
|
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
@@ -810,6 +877,10 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
810
877
|
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(", ") : "-"),
|
|
811
878
|
span(row.servedBy ?? row.requestedModel ?? "-")];
|
|
812
879
|
}
|
|
880
|
+
if (s.panel === 7) {
|
|
881
|
+
const text = usageCells(s.usage, row.id, s.budget[row.id], at => relative(at, now));
|
|
882
|
+
return text.map((value, i) => i === 0 ? id(value, "info") : span(value, value === "-" || value === "unknown" || value === "no records" ? "muted" : i === 4 ? "attention" : undefined));
|
|
883
|
+
}
|
|
813
884
|
if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
|
|
814
885
|
if (s.panel === 3 && s.backlogView) {
|
|
815
886
|
const waits = waitingDeps(s, row);
|
|
@@ -884,7 +955,7 @@ function taskHints(row: any): string[] {
|
|
|
884
955
|
const review = row.state === "in_review";
|
|
885
956
|
const hints = [review ? "a reviewer" : "a assign"];
|
|
886
957
|
if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
|
|
887
|
-
if (review) hints.push(
|
|
958
|
+
if (review) hints.push(reviewHint(row.reviewer));
|
|
888
959
|
if (["proposed", "in_progress"].includes(row.state)) hints.push("R return");
|
|
889
960
|
return hints;
|
|
890
961
|
}
|
|
@@ -894,7 +965,7 @@ function panelContext(s: ConsoleState, row: any): string[] {
|
|
|
894
965
|
if (!row) return [];
|
|
895
966
|
if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
|
|
896
967
|
if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
|
|
897
|
-
if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
|
|
968
|
+
if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"]; // D (the peer's whole backlog) is in the ? key table: it must not push j/k off an 80-column footer
|
|
898
969
|
return [];
|
|
899
970
|
}
|
|
900
971
|
/** The footer's key hint: only keys that act in this mode, panel and state. */
|
|
@@ -903,7 +974,9 @@ function hint(s: ConsoleState, columns: number): string {
|
|
|
903
974
|
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(" ");
|
|
904
975
|
if (s.help) return "? or Esc close q quit";
|
|
905
976
|
const approvals = s.panel === 2 ? pending : [];
|
|
906
|
-
|
|
977
|
+
// An open task detail names the review action too (#711): the detail is where the person reads the task, so it is where they decide.
|
|
978
|
+
const shown = s.panel === 3 && s.detail && typeof s.detail === "object" && (s.detail as { state?: unknown }).state === "in_review" ? reviewHint((s.detail as { reviewer?: unknown }).reviewer) : undefined;
|
|
979
|
+
if (s.detail !== undefined || s.requestDetail) return [...approvals, ...(shown ? [shown] : []), "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
|
|
907
980
|
const rows = panelRows(s); const row = selectedPanelRow(s);
|
|
908
981
|
const text = [...approvals, ...panelContext(s, row), ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
|
|
909
982
|
if (s.panel === 3 && Bun.stringWidth(text) > columns) {
|
|
@@ -918,14 +991,22 @@ function hint(s: ConsoleState, columns: number): string {
|
|
|
918
991
|
/** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
|
|
919
992
|
function header(s: ConsoleState, columns: number): Span[] {
|
|
920
993
|
const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
tabs
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
994
|
+
const project = s.project ?? "agent-hub";
|
|
995
|
+
// Seven tabs fill an 80-column line, which would squeeze the project name out: below that, one space before a tab instead of two (#683 U3).
|
|
996
|
+
const build = (lead: string) => {
|
|
997
|
+
const tabs: Span[] = [];
|
|
998
|
+
for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings", "Usage"].entries()) {
|
|
999
|
+
const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
|
|
1000
|
+
tabs.push(span(active ? lead.slice(1) + "[" : lead, tone), span(`${i + 1} ${name}`, tone));
|
|
1001
|
+
if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
|
|
1002
|
+
tabs.push(span(active ? "]" : " ", tone));
|
|
1003
|
+
}
|
|
1004
|
+
return tabs;
|
|
1005
|
+
};
|
|
1006
|
+
const width = (tabs: Span[]) => Bun.stringWidth(tabs.map(t => t.text).join(""));
|
|
1007
|
+
let tabs = build(" ");
|
|
1008
|
+
if (columns - width(tabs) < Math.min(Bun.stringWidth(project), 8)) tabs = build(" ");
|
|
1009
|
+
return fitLine([span(fit(project, Math.max(0, columns - width(tabs))), "info"), ...tabs], columns);
|
|
929
1010
|
}
|
|
930
1011
|
/** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
|
|
931
1012
|
const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
|
|
@@ -968,6 +1049,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
968
1049
|
prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
|
|
969
1050
|
}
|
|
970
1051
|
if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
|
|
1052
|
+
if (s.reviewChoice) prompt = choicePrompt(s.reviewChoice);
|
|
971
1053
|
const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
|
|
972
1054
|
if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
|
|
973
1055
|
const summary: Span[] = [];
|
|
@@ -994,7 +1076,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
994
1076
|
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
995
1077
|
if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
|
|
996
1078
|
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
|
|
997
|
-
fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
|
|
1079
|
+
fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : s.editing && s.reviewGuide ? [span(s.reviewGuide, "info")] : approvals, columns),
|
|
998
1080
|
fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
|
|
999
1081
|
const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
|
|
1000
1082
|
if (s.mode === "stream") {
|
|
@@ -1020,7 +1102,9 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1020
1102
|
}
|
|
1021
1103
|
else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
|
|
1022
1104
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
1023
|
-
const
|
|
1105
|
+
const usagePeer = s.panel === 7 && s.detail && typeof s.detail === "object" ? (s.detail as { usagePeer?: unknown }).usagePeer : undefined;
|
|
1106
|
+
const detail = typeof usagePeer === "string" ? fieldLines(Object.entries(usageDetail(s.usage, usagePeer, s.budget[usagePeer], at => relative(at, now))), columns, now).flat()
|
|
1107
|
+
: s.detail !== undefined ? detailLines(s.detail, columns, now, s.panel === 3) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
1024
1108
|
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
1025
1109
|
}
|
|
1026
1110
|
else {
|
|
@@ -1031,10 +1115,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1031
1115
|
const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
|
|
1032
1116
|
if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
|
|
1033
1117
|
const tail = [...allow, ...deny];
|
|
1034
|
-
|
|
1118
|
+
// The Usage panel's selected-task lines are reserved before the peer table is sized, so many peers can never push them off a short terminal (#683 U3).
|
|
1119
|
+
// At most half the body goes to them; a longer list is cut with a count. The status line and the table header take the other two rows.
|
|
1120
|
+
let taskLines = s.panel === 7 ? usageTaskLines(s.usage, s.usageTask) : [];
|
|
1121
|
+
const taskRoom = Math.max(1, Math.floor(height / 2));
|
|
1122
|
+
if (taskLines.length > taskRoom) taskLines = [...taskLines.slice(0, taskRoom - 1), { text: ` ... ${taskLines.length - taskRoom + 1} more`, tone: "muted" as const }];
|
|
1123
|
+
const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : s.panel === 7 ? height - 2 - taskLines.length : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
|
|
1035
1124
|
const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
1036
1125
|
const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
|
|
1037
|
-
const rowsOf = head ? s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
|
|
1126
|
+
const rowsOf = head ? s.panel === 7 ? optionalTable(head, cellRows, columns, USAGE_DROP) : s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
|
|
1038
1127
|
if (s.panel === 6) {
|
|
1039
1128
|
const row = selectedPanelRow(s);
|
|
1040
1129
|
const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
|
|
@@ -1045,6 +1134,11 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1045
1134
|
for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
|
|
1046
1135
|
}
|
|
1047
1136
|
}
|
|
1137
|
+
if (s.panel === 7) {
|
|
1138
|
+
const note = s.usage?.problem ? `usage unavailable: ${s.usage.problem}` : !s.usage?.life ? "usage loading..." : s.usage.behind ? "usage still catching up with the event log: numbers are partial and refresh in a few seconds"
|
|
1139
|
+
: `usage through ${s.usage.at ? relative(s.usage.at, now) : "now"}: today is the UTC day, 7d the last seven UTC days`;
|
|
1140
|
+
lines.push(fitLine([span(note, s.usage?.problem ? "failure" : !s.usage?.life || s.usage.behind ? "attention" : "info")], columns));
|
|
1141
|
+
}
|
|
1048
1142
|
if (s.panel === 3 && s.backlogView) {
|
|
1049
1143
|
lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
|
|
1050
1144
|
} else if (s.panel === 3) {
|
|
@@ -1065,6 +1159,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1065
1159
|
lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
|
|
1066
1160
|
}
|
|
1067
1161
|
if (!data.length) lines.push([span(" (empty)")]);
|
|
1162
|
+
if (s.panel === 7) for (const line of taskLines) lines.push(fitLine([span(" " + line.text, line.tone)], columns));
|
|
1068
1163
|
if (title.length) {
|
|
1069
1164
|
const room = Math.max(1, height - lines.length - 1 - tail.length);
|
|
1070
1165
|
if (title.length > room) { title.length = room; title[room - 1] = more(title[room - 1]!); }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { planSymbolMatches } from "../hub/review-brief.ts";
|
|
2
|
+
|
|
3
|
+
// #684 2b: the "changed symbols" block of the console's task detail. Pure data to fields, no terminal code. The hub's own observation of the changed
|
|
4
|
+
// symbols (`codeSymbols`, PR #708) is read as it is; nothing is parsed from code here and nothing is asked of the hub beyond `task_show`.
|
|
5
|
+
// Advisory only: ripwire does not report deleted symbols, so these lists never establish that a plan was or was not followed. The plan names are `plan.symbols`,
|
|
6
|
+
// the same list `withCodeSymbols` reads as `planned` in the review brief (daemon.ts passes `task.plan?.symbols`), not a second source.
|
|
7
|
+
|
|
8
|
+
export interface ChangedSymbol { id: string; path: string; name: string; kind: string }
|
|
9
|
+
export interface SymbolBlock { state: "known" | "unknown"; complete: boolean; head?: string; matching: ChangedSymbol[]; unplanned: ChangedSymbol[]; untouched: string[] }
|
|
10
|
+
|
|
11
|
+
const MAX_LINES = 40;
|
|
12
|
+
const isPrivate = (task: any): boolean =>
|
|
13
|
+
!task || (Array.isArray(task.signals) && task.signals.some((s: unknown) => s === "pii" || s === "pii_path")) || task.title === "[pii]";
|
|
14
|
+
const symbolsOf = (task: any): ChangedSymbol[] | undefined => {
|
|
15
|
+
const received = task?.codeSymbols;
|
|
16
|
+
if (!received || received.state !== "known" || !Array.isArray(received.symbols)) return undefined;
|
|
17
|
+
// Only the public receipt (`publicTaskCodeSymbols`, re-screened on every read) is shown. A stored receipt still carries `repositoryKey` and was never screened for
|
|
18
|
+
// the current policy: a task handed over in that shape reads as unknown, whatever feeds this renderer today (the renderer holds the rule itself).
|
|
19
|
+
if (Object.hasOwn(received, "repositoryKey")) return undefined;
|
|
20
|
+
const out: ChangedSymbol[] = [];
|
|
21
|
+
for (const s of received.symbols) if (s && typeof s.id === "string" && typeof s.path === "string" && typeof s.name === "string" && typeof s.kind === "string") out.push({ id: s.id, path: s.path, name: s.name, kind: s.kind });
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Each changed symbol as matching the plan or unplanned, and each planned name nothing changed under as untouched. Unknown without code facts. */
|
|
26
|
+
export function classifySymbols(task: any): SymbolBlock {
|
|
27
|
+
const changed = symbolsOf(task);
|
|
28
|
+
if (!changed) return { state: "unknown", complete: false, matching: [], unplanned: [], untouched: [] };
|
|
29
|
+
const plan: string[] = Array.isArray(task?.plan?.symbols) ? task.plan.symbols.filter((v: unknown): v is string => typeof v === "string") : [];
|
|
30
|
+
const matching = changed.filter(s => plan.some(p => planSymbolMatches(p, s.name)));
|
|
31
|
+
const unplanned = changed.filter(s => !plan.some(p => planSymbolMatches(p, s.name)));
|
|
32
|
+
const untouched = plan.filter(p => !changed.some(s => planSymbolMatches(p, s.name)));
|
|
33
|
+
return { state: "known", complete: task.codeSymbols.complete === true, ...(typeof task.codeSymbols.head === "string" ? { head: task.codeSymbols.head.slice(0, 8) } : {}), matching, unplanned, untouched };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The detail fields for a task (`symbols` and `symbols note`), or none for a private one. Values are objects of single words (`{ matching: name, in: path }`) so the console's field
|
|
38
|
+
* renderer quotes every agent-written string: a symbol name cannot pass for a hub label. Capped, with the count of what is left out.
|
|
39
|
+
*/
|
|
40
|
+
export function symbolFields(task: any): [string, unknown][] {
|
|
41
|
+
// Only a task as `task_show` shapes it (a numeric id and a state): any other detail an agent or a test hands the Tasks panel keeps its own layout.
|
|
42
|
+
if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task)) return [];
|
|
43
|
+
const block = classifySymbols(task);
|
|
44
|
+
if (block.state === "unknown") return [["symbols", "changed symbols: unknown (no code facts)"]];
|
|
45
|
+
const items: Record<string, string>[] = [
|
|
46
|
+
...block.matching.map(s => ({ matching: s.name, in: s.path })), ...block.unplanned.map(s => ({ unplanned: s.name, in: s.path })), ...block.untouched.map(name => ({ untouched: name })),
|
|
47
|
+
];
|
|
48
|
+
const shown = items.slice(0, MAX_LINES);
|
|
49
|
+
const note = [`changed symbols: ${block.matching.length} matching, ${block.unplanned.length} unplanned, ${block.untouched.length} untouched${block.head ? ` at ${block.head}` : ""}`,
|
|
50
|
+
block.complete ? "" : "incomplete: some changed symbols were not listed, so an untouched one may have changed",
|
|
51
|
+
"advisory: deleted symbols are not reported"].filter(Boolean).join("; ");
|
|
52
|
+
return [["symbols", shown.length ? shown : "none observed"], ["symbols note", items.length > shown.length ? `${note}; ${items.length - shown.length} more not shown` : note]];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/** Render the hub's read-only pair verdicts; never derive disjointness from a local receipt or plan. */
|
|
57
|
+
export function overlapFields(task: any): [string, unknown][] {
|
|
58
|
+
if (!task || typeof task.id !== "number" || typeof task.state !== "string" || isPrivate(task) || !Array.isArray(task.symbolOverlaps)) return [];
|
|
59
|
+
if (!task.symbolOverlaps.length) return [["overlaps", "none"]];
|
|
60
|
+
const items: Record<string, unknown>[] = [];
|
|
61
|
+
let invalid = 0;
|
|
62
|
+
for (const row of task.symbolOverlaps) {
|
|
63
|
+
if (!row || !Number.isSafeInteger(row.task) || row.task < 1 || !Array.isArray(row.files)
|
|
64
|
+
|| !row.files.every((file: unknown) => typeof file === "string") || typeof row.sameFileDifferentSymbols !== "boolean") { invalid++; continue; }
|
|
65
|
+
items.push({ task: row.task, files: row.files.slice(0, MAX_LINES),
|
|
66
|
+
overlap: row.sameFileDifferentSymbols ? "same file, different symbols" : "overlap",
|
|
67
|
+
...(row.files.length > MAX_LINES ? { omitted: `${row.files.length - MAX_LINES} files not shown` } : {}) });
|
|
68
|
+
}
|
|
69
|
+
if (!items.length) return [["overlaps", "unknown (invalid overlap data)"]];
|
|
70
|
+
const note = ["observed names only, deleted definitions may be missing",
|
|
71
|
+
items.length > MAX_LINES ? `${items.length - MAX_LINES} more not shown` : "",
|
|
72
|
+
invalid ? `${invalid} invalid rows withheld` : ""].filter(Boolean).join("; ");
|
|
73
|
+
return [["overlaps", items.slice(0, MAX_LINES)], ["overlap note", note]];
|
|
74
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Counter, LimitsResult, UsageResult, UsageRow } from "../hub/usage-rollup.ts";
|
|
2
|
+
|
|
3
|
+
// #683 U3: what the console's Usage panel shows, as plain strings. No terminal code and no state import here: console-state.ts renders it.
|
|
4
|
+
// Every number names its source, an unknown counter reads `unknown` (never 0), and a private task is `#id [pii]` with no title.
|
|
5
|
+
|
|
6
|
+
/** What the console keeps from the `usage` op: one query per period, the limits history and, for the task selected in Tasks, its tokens per peer. */
|
|
7
|
+
export interface UsageState {
|
|
8
|
+
today?: UsageResult; week?: UsageResult; life?: UsageResult; limits?: LimitsResult;
|
|
9
|
+
/** The selected task's tokens per peer, with the task id it was asked for. */
|
|
10
|
+
task?: UsageResult; taskId?: number;
|
|
11
|
+
at?: number; problem?: string;
|
|
12
|
+
/** The hub's roll-up was still behind the event log when it answered: the numbers are partial (a later poll catches up). */
|
|
13
|
+
behind?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface UsagePeerRow { id: string }
|
|
16
|
+
|
|
17
|
+
const TAG: Record<Counter["source"], string> = { native: "native", count: "hub", manual: "manual", partial: "at least", unknown: "unknown" };
|
|
18
|
+
const compact = (n: number): string => n >= 1e7 ? `${Math.round(n / 1e6)}M` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
19
|
+
/** `1.2M native`, `77 hub`, or `unknown`: a counter nobody reported is never shown as a number. */
|
|
20
|
+
export const counterText = (c: Counter | undefined, exact = false): string => !c || c.value === null ? "unknown" : `${exact ? c.value : compact(c.value)} ${TAG[c.source]}`;
|
|
21
|
+
/** A row's tokens: the provider's total when it reported one, otherwise what the hub counted, otherwise unknown. */
|
|
22
|
+
export const rowTokens = (row: UsageRow | undefined): Counter =>
|
|
23
|
+
row && row.total.value !== null ? row.total : row && row.counted.value !== null ? row.counted : { value: null, source: "unknown" };
|
|
24
|
+
const periodText = (row: UsageRow | undefined, exact = false): string => row ? counterText(rowTokens(row), exact) : "no records";
|
|
25
|
+
|
|
26
|
+
const rowOf = (result: UsageResult | undefined, peer: string): UsageRow | undefined => result?.rows.find(row => row.key === peer);
|
|
27
|
+
|
|
28
|
+
/** Every peer the panel can say something about: peers with usage in any period, and peers with a quota reading. */
|
|
29
|
+
export function usagePeers(usage: UsageState | undefined, budget: Record<string, unknown>, peers: Record<string, unknown>): UsagePeerRow[] {
|
|
30
|
+
const ids = new Set<string>([...Object.keys(peers), ...Object.keys(budget)]);
|
|
31
|
+
for (const result of [usage?.today, usage?.week, usage?.life]) for (const row of result?.rows ?? []) ids.add(row.key);
|
|
32
|
+
return [...ids].sort().map(id => ({ id }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Budget { windows?: { id: string; used: number; resetsAt?: number }[]; paused?: { resetsAt: number } }
|
|
36
|
+
const windowText = (w: { id: string; used: number; resetsAt?: number }, rel: (at: number) => string) =>
|
|
37
|
+
`${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${rel(w.resetsAt)}` : ""}`;
|
|
38
|
+
const lastResets = (limits: LimitsResult | undefined, peer: string, count: number) =>
|
|
39
|
+
(limits?.history ?? []).filter(h => h.type === "quota_reset" && h.peer === peer).slice(-count).reverse();
|
|
40
|
+
|
|
41
|
+
/** One table row: PEER, TODAY, 7D, LIFETIME, PAUSE, QUOTA, LAST RESET (PAUSE before QUOTA: a pause must survive a narrow terminal, the long quota text is what gives way). */
|
|
42
|
+
export function usageCells(usage: UsageState | undefined, peer: string, budget: Budget | undefined, rel: (at: number) => string): string[] {
|
|
43
|
+
const reset = lastResets(usage?.limits, peer, 1)[0];
|
|
44
|
+
// The table is tight at 80 columns: a time still to come reads `3h00m`, not `in 3h00m` (the detail view spells it out).
|
|
45
|
+
const ahead = (at: number) => rel(at).replace(/^in /, "");
|
|
46
|
+
return [peer, periodText(rowOf(usage?.today, peer)), periodText(rowOf(usage?.week, peer)), periodText(rowOf(usage?.life, peer)),
|
|
47
|
+
budget?.paused ? ahead(budget.paused.resetsAt) : "-",
|
|
48
|
+
budget?.windows?.length ? budget.windows.map(w => windowText(w, ahead)).join(", ") : "-",
|
|
49
|
+
reset ? `${reset.window} ${rel(Date.parse(reset.at))}` : "-"];
|
|
50
|
+
}
|
|
51
|
+
export const USAGE_HEAD = ["PEER", "TODAY", "7D", "LIFETIME", "PAUSE", "QUOTA", "LAST RESET"];
|
|
52
|
+
/** Only the last reset gives way to width; the others are cut shorter instead, so the 7D column and the pause are never dropped. */
|
|
53
|
+
export const USAGE_DROP = ["LAST RESET"];
|
|
54
|
+
|
|
55
|
+
/** The token split of one period: input, output, cache read, cache write, the total and the hub's own count, each with its source. */
|
|
56
|
+
export function splitText(row: UsageRow | undefined): string {
|
|
57
|
+
if (!row) return "no records";
|
|
58
|
+
return [`total ${counterText(row.total, true)}`, `input ${counterText(row.input, true)}`, `output ${counterText(row.output, true)}`,
|
|
59
|
+
`cache read ${counterText(row.cacheRead, true)}`, `cache write ${counterText(row.cacheWrite, true)}`, `hub count ${counterText(row.counted, true)}`].join(", ");
|
|
60
|
+
}
|
|
61
|
+
export const taskLabel = (usage: UsageState | undefined): string =>
|
|
62
|
+
usage?.taskId === undefined ? "" : `#${usage.taskId}${usage.task?.taskPii ? " [pii]" : ""}`;
|
|
63
|
+
|
|
64
|
+
/** The detail of one peer, as labelled one-line-per-field text. Values are hub-built from ids and numbers only. */
|
|
65
|
+
export function usageDetail(usage: UsageState | undefined, peer: string, budget: Budget | undefined, rel: (at: number) => string): Record<string, string> {
|
|
66
|
+
const resets = lastResets(usage?.limits, peer, 3).map(h => `${h.window} ${rel(Date.parse(h.at))}`);
|
|
67
|
+
const out: Record<string, string> = {
|
|
68
|
+
peer,
|
|
69
|
+
quota: budget?.windows?.length ? budget.windows.map(w => windowText(w, rel)).join(", ") : "no reading",
|
|
70
|
+
pause: budget?.paused ? `budget pause, resumes ${rel(budget.paused.resetsAt)}` : "none",
|
|
71
|
+
"last resets": resets.length ? resets.join(", ") : "none recorded",
|
|
72
|
+
today: splitText(rowOf(usage?.today, peer)),
|
|
73
|
+
"last 7 days": splitText(rowOf(usage?.week, peer)),
|
|
74
|
+
lifetime: splitText(rowOf(usage?.life, peer)),
|
|
75
|
+
};
|
|
76
|
+
if (usage?.taskId !== undefined) out[`task ${taskLabel(usage)}`] = splitText(rowOf(usage.task, peer));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The lines under the table: the task selected in Tasks and its tokens per peer. */
|
|
81
|
+
export function usageTaskLines(usage: UsageState | undefined, selectedTask: number | undefined): { text: string; tone?: "muted" | "info" | "failure" }[] {
|
|
82
|
+
if (selectedTask === undefined) return [{ text: "task: none selected; pick one in Tasks (3), then open Usage (7)", tone: "muted" }];
|
|
83
|
+
if (usage?.problem) return [];
|
|
84
|
+
if (usage?.taskId !== selectedTask || !usage.task) return [{ text: `task #${selectedTask}: loading...`, tone: "muted" }];
|
|
85
|
+
const rows = usage.task.rows;
|
|
86
|
+
const head = { text: `task ${taskLabel(usage)}: tokens per peer, all time`, tone: "info" as const };
|
|
87
|
+
if (!rows.length) return [head, { text: " no usage recorded for this task", tone: "muted" }];
|
|
88
|
+
return [head, ...rows.map(row => ({ text: ` ${row.key} ${counterText(rowTokens(row))}` }))];
|
|
89
|
+
}
|
package/src/cli/console.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
4
4
|
import type { ControlClient } from "../hub/control-client.ts";
|
|
5
5
|
import { contextLine } from "./status-lines.ts";
|
|
6
6
|
import { renderTailEvent } from "./tail-render.ts";
|
|
7
|
+
import { reviewResult } from "./console-review.ts";
|
|
7
8
|
import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow, appendEvent, FIRST_KEYS, LAST_KEYS } from "./console-state.ts";
|
|
8
9
|
import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
|
|
9
10
|
import type { BusEvent } from "../hub/bus.ts";
|
|
@@ -192,6 +193,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
192
193
|
...(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)] : []),
|
|
193
194
|
...(panels && (state.panel === 1 || state.panel === 6) ? [client.request({ t: "settings_get" }, 3000)] : []),
|
|
194
195
|
]);
|
|
196
|
+
// #683 U3: the Usage panel asks the hub's roll-up only while it is open (UTC day, 7 UTC days, lifetime, the limits history and the selected task).
|
|
197
|
+
// The task is read at the time of the request, so a reply for a task that is no longer selected is dropped below.
|
|
198
|
+
const usageTask = panels && state.panel === 7 ? state.usageTask : undefined;
|
|
199
|
+
const usageReplies = panels && state.panel === 7 ? await (async () => {
|
|
200
|
+
const startOfDay = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
|
|
201
|
+
return Promise.all([client.request({ t: "usage", by: "peer", since: startOfDay }, 5000), client.request({ t: "usage", by: "peer", since: startOfDay - 6 * 86_400_000 }, 5000),
|
|
202
|
+
client.request({ t: "usage", by: "peer" }, 5000), client.request({ t: "usage", op: "limits", limit: 1000 }, 5000),
|
|
203
|
+
...(usageTask !== undefined ? [client.request({ t: "usage", by: "peer", task: usageTask }, 5000)] : [])]);
|
|
204
|
+
})() : undefined;
|
|
195
205
|
if (!active) return;
|
|
196
206
|
const [status, budget, tasks, queue, ready, settings] = replies;
|
|
197
207
|
captureConsoleSelection(state);
|
|
@@ -217,6 +227,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
217
227
|
}
|
|
218
228
|
if (syncPermissionDefaults(state, status.status?.permissionDefaults) && state.confirm?.type === "permission_default") stream({ text: permissionDefaultText(state.confirm), kind: "permission_default", peer: state.confirm.peer, tone: "attention" });
|
|
219
229
|
if (budget.budget) state.budget = budget.budget;
|
|
230
|
+
if (usageReplies) {
|
|
231
|
+
const [today, week, life, limits, task] = usageReplies, failed = usageReplies.find(reply => reply?.ok === false);
|
|
232
|
+
if (failed || !today?.usage || !week?.usage || !life?.usage || !limits?.limits) state.usage = { ...state.usage, problem: String(failed?.error ?? "usage is unavailable") };
|
|
233
|
+
else state.usage = { today: today.usage, week: week.usage, life: life.usage, limits: limits.limits, at: Date.now(), ...(usageReplies.some(reply => reply?.behind) ? { behind: true } : {}),
|
|
234
|
+
...(usageTask !== undefined && task?.usage && state.usageTask === usageTask ? { task: task.usage, taskId: usageTask } : {}) };
|
|
235
|
+
}
|
|
220
236
|
if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
|
|
221
237
|
const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
|
|
222
238
|
state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
|
|
@@ -304,7 +320,16 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
304
320
|
let diagnostic = "";
|
|
305
321
|
// Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
|
|
306
322
|
const output = (text: string) => { if (active) { diagnostic = (diagnostic + terminalText(text)).slice(-2000); stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
|
|
307
|
-
cancelChild = run(action.args, output, (code = 0) => {
|
|
323
|
+
cancelChild = run(action.args, output, (code = 0) => {
|
|
324
|
+
childRunning = false; cancelChild = undefined;
|
|
325
|
+
if (active) {
|
|
326
|
+
// #711: a review's result is a notice in panels mode too (where the stream is not on screen): `#372 approved`, or the CLI's own refusal verbatim.
|
|
327
|
+
const review = reviewResult(action.args, code, diagnostic.trim());
|
|
328
|
+
if (review) notify(state, review.text, Date.now(), review.tone === "success" ? "success" : "failure");
|
|
329
|
+
else if (code) notice(diagnostic.trim() || `command exited ${code}`);
|
|
330
|
+
void refresh(); draw();
|
|
331
|
+
}
|
|
332
|
+
}, Math.max(1, columns - 4));
|
|
308
333
|
} catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
|
|
309
334
|
};
|
|
310
335
|
const handle = (key: string) => {
|