@staix/agent-hub 0.12.20 → 0.12.21
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 +22 -0
- package/README.md +6 -5
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/bus.md +5 -2
- package/docs/agent-notes/daemon.md +8 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +2 -1
- package/docs/operations.md +271 -46
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +45 -10
- package/docs/smoke.md +79 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
- package/docs/verified.json +28 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/codex-appserver.ts +67 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +10 -0
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +96 -17
- package/src/cli/main.ts +159 -18
- package/src/cli/preview.ts +13 -3
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-runtime.ts +67 -18
- package/src/cli/upgrade.ts +81 -9
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +19 -4
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +346 -40
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +2 -1
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +21 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +70 -43
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/tools.ts +14 -10
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +83 -6
- package/src/ui/task-progress.ts +33 -0
package/src/cli/console-state.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { PERMISSION_MODES } from "../hub/permission-mode.ts";
|
|
2
|
+
import { taskProgress } from "../ui/task-progress.ts";
|
|
3
|
+
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
1
4
|
import { sanitize } from "../hub/envelope.ts";
|
|
2
5
|
|
|
3
6
|
export interface Approval {
|
|
4
7
|
id: string; peer: string; title: string; expiresAt: number;
|
|
5
8
|
options: { optionId: string; name: string; kind: string }[];
|
|
6
9
|
}
|
|
10
|
+
export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
|
|
7
11
|
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
8
12
|
export interface ConsoleState {
|
|
9
13
|
mode: "stream" | "panels"; panel: number; selection: number;
|
|
@@ -15,18 +19,57 @@ export interface ConsoleState {
|
|
|
15
19
|
autoSelected?: boolean;
|
|
16
20
|
input: string; editing: boolean; history: string[]; historyIndex: number;
|
|
17
21
|
approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
|
|
18
|
-
tasks: any[]; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
22
|
+
tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
19
23
|
peerFilter?: string; kindFilter?: string; project?: string;
|
|
20
|
-
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
|
|
24
|
+
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
|
|
21
25
|
optionChoice?: string;
|
|
26
|
+
modeChoice?: string;
|
|
27
|
+
permissionDefaults: PermissionDefault[];
|
|
28
|
+
permissionDefaultsHandled: string[];
|
|
22
29
|
}
|
|
23
30
|
/** A detail view: text as the stream shows it, or labeled fields. */
|
|
24
31
|
export type Detail = string | Record<string, unknown>;
|
|
25
32
|
export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
|
|
26
|
-
{ type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
|
|
33
|
+
{ type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
|
|
27
34
|
export function initialConsoleState(panels = false): ConsoleState {
|
|
28
35
|
return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
|
|
29
|
-
input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
|
|
36
|
+
input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
|
|
37
|
+
}
|
|
38
|
+
/** Approval frequency does not create a common sandbox across native peers. */
|
|
39
|
+
export function permissionBoundary(peer: string): string {
|
|
40
|
+
if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
41
|
+
if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
42
|
+
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
43
|
+
}
|
|
44
|
+
function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
|
|
45
|
+
if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
|
|
46
|
+
if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
|
|
47
|
+
if (mode === "unknown") return `${peer} permission mode is unknown; inspect or reconnect its native session`;
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Queue sizes and timestamps do not make a refused/default-deferred peer ready. */
|
|
51
|
+
function permissionDefaultFingerprint(s: ConsoleState, row: Pick<PermissionDefault, "peer" | "source">): string {
|
|
52
|
+
const peer = s.peers[row.peer];
|
|
53
|
+
// Readiness, not activity: a peer going idle or busy does not make a deferred default worth asking about again.
|
|
54
|
+
return JSON.stringify([row.peer, row.source, !peer ? "absent" : peer.state === "offline" ? "offline" : "attached", peer?.permissionMode ?? "ask", peer?.attached ?? !!peer, peer?.claiming ?? false, peer?.toolsOnly ?? false]);
|
|
55
|
+
}
|
|
56
|
+
/** Offer one file default only when no other keyboard decision or edit is active. */
|
|
57
|
+
export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean {
|
|
58
|
+
if (!Array.isArray(rows)) rows = [];
|
|
59
|
+
s.permissionDefaults = (rows as unknown[]).filter((row): row is PermissionDefault => !!row && typeof row === "object" &&
|
|
60
|
+
typeof (row as PermissionDefault).peer === "string" && (row as PermissionDefault).mode === "never-ask" && typeof (row as PermissionDefault).source === "string");
|
|
61
|
+
const current = new Set(s.permissionDefaults.map(row => permissionDefaultFingerprint(s, row)));
|
|
62
|
+
s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
|
|
63
|
+
const pending = s.confirm;
|
|
64
|
+
if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
|
|
65
|
+
if (s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
|
|
66
|
+
const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
|
|
67
|
+
if (!next) return false;
|
|
68
|
+
s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
|
|
72
|
+
return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
|
|
30
73
|
}
|
|
31
74
|
/** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
|
|
32
75
|
export function terminalText(value: unknown): string {
|
|
@@ -36,10 +79,12 @@ export function terminalText(value: unknown): string {
|
|
|
36
79
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
37
80
|
.replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
|
|
38
81
|
}
|
|
39
|
-
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted";
|
|
82
|
+
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
|
|
40
83
|
export interface Span { text: string; tone?: Tone }
|
|
41
84
|
export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
|
|
42
85
|
info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
|
|
86
|
+
peerClaude: "\x1b[94m", peerCodex: "\x1b[96m",
|
|
87
|
+
taskKeyword: "\x1b[35m", number: "\x1b[1m", issueRef: "\x1b[4m", taskRef: "\x1b[4;35m",
|
|
43
88
|
});
|
|
44
89
|
export function paint(line: Span[], color: boolean): string {
|
|
45
90
|
return line.map(span => {
|
|
@@ -48,6 +93,83 @@ export function paint(line: Span[], color: boolean): string {
|
|
|
48
93
|
return color && sgr && text ? sgr + text + "\x1b[0m" : text;
|
|
49
94
|
}).join("");
|
|
50
95
|
}
|
|
96
|
+
/** Console stream headers only. Tokenize sanitized text; bodies and command output stay plain. */
|
|
97
|
+
export function streamTokens(value: string, eventTone?: Tone, kind?: string): Span[] {
|
|
98
|
+
const text = terminalText(value);
|
|
99
|
+
if (kind === "command" || kind === "console") return [{ text }];
|
|
100
|
+
const peers: Readonly<Record<string, Tone>> = { claude: "peerClaude", codex: "peerCodex" };
|
|
101
|
+
// Hub-written peer slots only; words such as local/pi/hub in a title do not identify a speaker.
|
|
102
|
+
const peerSlots = new Set<number>();
|
|
103
|
+
const leading = text.match(/^\s*(?:[.?*!]\s+)?([A-Za-z_][A-Za-z_0-9-]*)(?=:| is | asks permission:| permission )/);
|
|
104
|
+
if (leading) peerSlots.add(leading[0].lastIndexOf(leading[1]!));
|
|
105
|
+
const route = text.match(/^\s*(?:[0-9:]+ (?:AM|PM) )?([A-Za-z_][A-Za-z_0-9-]*) -> ([A-Za-z_][A-Za-z_0-9,-]*|\*)/);
|
|
106
|
+
if (route) {
|
|
107
|
+
const start = route[0].indexOf(route[1]!); peerSlots.add(start);
|
|
108
|
+
let at = route[0].lastIndexOf(route[2]!);
|
|
109
|
+
for (const recipient of route[2]!.split(",")) { peerSlots.add(at); at += recipient.length + 1; }
|
|
110
|
+
}
|
|
111
|
+
if (/^\s*\* task #[0-9]+ .+ (?:accepted by|assigned to) [A-Za-z_][A-Za-z_0-9-]*$/.test(text) && !/ declined| reason:/.test(text)) {
|
|
112
|
+
const owner = text.match(/(?:accepted by|assigned to) ([A-Za-z_][A-Za-z_0-9-]*)$/);
|
|
113
|
+
if (owner) peerSlots.add(owner.index! + owner[0].lastIndexOf(owner[1]!));
|
|
114
|
+
}
|
|
115
|
+
const marker = text.match(/^\s*([.?*!])/);
|
|
116
|
+
const markerAt = marker ? marker[0].length - 1 : -1;
|
|
117
|
+
const state = kind === "state" ? text.match(/ is ([A-Za-z_]+)$/) : undefined;
|
|
118
|
+
const stateAt = state ? state.index! + 4 : -1;
|
|
119
|
+
const permissionIds = new Set<number>();
|
|
120
|
+
for (const match of text.matchAll(/\b(?:permission|request|permit) ([0-9]{8})(?=\b)/g)) permissionIds.add(match.index + match[0].length - 8);
|
|
121
|
+
const taskKeyword = text.match(/^\s*\*?\s*(task)(?![\w-])/i);
|
|
122
|
+
const taskKeywordAt = taskKeyword ? taskKeyword[0].length - taskKeyword[1]!.length : -1;
|
|
123
|
+
const out: Span[] = []; let end = 0; let previousToken = ""; let previousStart = -1;
|
|
124
|
+
for (const match of text.matchAll(/(?<![\p{L}\p{N}_#-])#[0-9]+(?![\p{L}\p{N}_-])|(?<![\p{L}\p{N}_#.:-])(?:[0-9]+(?::[0-9]+)+(?: AM| PM)?|[0-9]+(?:\.[0-9]+)?(?:%|ms|s|m|h)?)(?![\p{L}\p{N}_%-]|[.:][\p{L}\p{N}])|[A-Za-z_][A-Za-z_0-9-]*|->|[.?*!]/gu)) {
|
|
125
|
+
const token = match[0]; const start = match.index;
|
|
126
|
+
const gap = text.slice(end, start);
|
|
127
|
+
if (gap) out.push({ text: gap });
|
|
128
|
+
let tone: Tone | undefined;
|
|
129
|
+
// ponytail: legacy notices have no typed reference ranges. Classify task/review prefixes textually;
|
|
130
|
+
// assign/which #N remains an issue reference until notices carry structured reference spans.
|
|
131
|
+
if (token.startsWith("#")) {
|
|
132
|
+
const task = previousToken.toLowerCase() === "task" && /^\s*$/.test(gap);
|
|
133
|
+
const review = previousToken.toLowerCase() === "review" && /^\s*$/.test(gap) && text[previousStart - 1] === "[" && text[start + token.length] === "]";
|
|
134
|
+
tone = task || review ? "taskRef" : "issueRef";
|
|
135
|
+
}
|
|
136
|
+
else if (start === taskKeywordAt) tone = "taskKeyword";
|
|
137
|
+
else if (/^[0-9]/.test(token) && !permissionIds.has(start)) tone = "number";
|
|
138
|
+
else if (peerSlots.has(start) && Object.hasOwn(peers, token)) tone = peers[token];
|
|
139
|
+
else if (start === markerAt || start === stateAt || (route && token === "->" && start < route[0].length)) tone = eventTone;
|
|
140
|
+
out.push({ text: token, ...(tone ? { tone } : {}) });
|
|
141
|
+
previousToken = token; previousStart = start; end = start + token.length;
|
|
142
|
+
}
|
|
143
|
+
if (end < text.length) out.push({ text: text.slice(end) });
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Wrap plain text first, then project original token spans onto it, preserving trusted slots across continuations. */
|
|
147
|
+
export function wrapStreamTokens(value: string, columns: number, tone?: Tone, kind?: string): Span[][] {
|
|
148
|
+
const spans = streamTokens(terminalText(value).replace(/\t/g, " "), tone, kind);
|
|
149
|
+
const text = spans.map(s => s.text).join("");
|
|
150
|
+
let cursor = 0; let projecting = true; let spanIndex = 0; let spanAt = 0;
|
|
151
|
+
return wrap(text, columns).map((line, index) => {
|
|
152
|
+
const content = line.trimStart();
|
|
153
|
+
const start = projecting ? text.indexOf(content, cursor) : -1;
|
|
154
|
+
if (!content || start < 0) {
|
|
155
|
+
if (content) projecting = false;
|
|
156
|
+
// Only the first physical header can have structural markers. Never infer structure from a continuation.
|
|
157
|
+
return index === 0 ? streamTokens(line, tone, kind).map(s => ({ text: s.text, ...(s.tone === tone && tone ? { tone } : {}) })) : [{ text: line }];
|
|
158
|
+
}
|
|
159
|
+
const end = start + content.length; cursor = end;
|
|
160
|
+
const out: Span[] = [{ text: line.slice(0, line.length - content.length) }];
|
|
161
|
+
while (spanIndex < spans.length && spanAt + spans[spanIndex]!.text.length <= start) {
|
|
162
|
+
spanAt += spans[spanIndex++]!.text.length;
|
|
163
|
+
}
|
|
164
|
+
while (spanIndex < spans.length && spanAt < end) {
|
|
165
|
+
const span = spans[spanIndex]!; const next = spanAt + span.text.length;
|
|
166
|
+
out.push({ ...span, text: span.text.slice(Math.max(0, start - spanAt), Math.min(span.text.length, end - spanAt)) });
|
|
167
|
+
if (next > end) break;
|
|
168
|
+
spanAt = next; spanIndex++;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
51
173
|
export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
|
|
52
174
|
if (flag === "always") return true;
|
|
53
175
|
if (flag === "never") return false;
|
|
@@ -188,7 +310,7 @@ const KEYS: [string, string[]][] = [
|
|
|
188
310
|
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
189
311
|
["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
|
|
190
312
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
191
|
-
["Peers", ["p pause", "r resume"]],
|
|
313
|
+
["Peers", ["p pause", "r resume", "m permission mode"]],
|
|
192
314
|
["Tasks", ["a assign (then y)", "r review"]],
|
|
193
315
|
["Queue", ["r resolve (reason, then y)"]],
|
|
194
316
|
["Events", ["f peer filter", "g kind filter"]],
|
|
@@ -222,7 +344,7 @@ function bound(s: ConsoleState): ConsoleState {
|
|
|
222
344
|
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
223
345
|
return s;
|
|
224
346
|
}
|
|
225
|
-
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
|
|
347
|
+
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
|
|
226
348
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
227
349
|
export function parseConsoleCommand(input: string): string[] {
|
|
228
350
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -241,7 +363,8 @@ export function parseConsoleCommand(input: string): string[] {
|
|
|
241
363
|
return words;
|
|
242
364
|
}
|
|
243
365
|
export function commandNeedsConfirmation(args: string[]): boolean {
|
|
244
|
-
|
|
366
|
+
const positional = args.filter(arg => arg !== "--yes");
|
|
367
|
+
return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
|
|
245
368
|
}
|
|
246
369
|
export function panelRows(s: ConsoleState): any[] {
|
|
247
370
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
@@ -264,8 +387,15 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
264
387
|
if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
|
|
265
388
|
if (s.confirm) {
|
|
266
389
|
const confirm = s.confirm; s.confirm = undefined;
|
|
390
|
+
if (confirm.type === "permission_default") {
|
|
391
|
+
if (key !== "y" && key !== "n" && key !== "\x1b") { s.confirm = confirm; return done(); }
|
|
392
|
+
s.permissionDefaultsHandled = [...s.permissionDefaultsHandled, permissionDefaultFingerprint(s, confirm)];
|
|
393
|
+
if (key === "\x1b") { notify(s, `${confirm.peer} never-ask default deferred; remains pending and ask`, now, "attention"); return done(); }
|
|
394
|
+
effects.push({ type: "permission_default", peer: confirm.peer, confirmed: key === "y" });
|
|
395
|
+
return done();
|
|
396
|
+
}
|
|
267
397
|
if (key === "y") {
|
|
268
|
-
if (confirm.type === "command") effects.push({ type: "command", args: confirm.args });
|
|
398
|
+
if (confirm.type === "command") effects.push({ type: "command", args: confirm.args[0] === "permission" && confirm.args[2] === "never-ask" ? [...confirm.args.filter(arg => arg !== "--yes"), "--yes"] : confirm.args });
|
|
269
399
|
else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
|
|
270
400
|
effects.push({ type: "permit", id: confirm.id, option: confirm.option });
|
|
271
401
|
answered(confirm.id);
|
|
@@ -273,6 +403,19 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
273
403
|
}
|
|
274
404
|
return done();
|
|
275
405
|
}
|
|
406
|
+
if (s.modeChoice) {
|
|
407
|
+
const peer = s.modeChoice;
|
|
408
|
+
if (key === "\x1b") { s.modeChoice = undefined; return done(); }
|
|
409
|
+
const mode = PERMISSION_MODES[Number(key) - 1];
|
|
410
|
+
if (/^[1-3]$/.test(key) && mode) {
|
|
411
|
+
s.modeChoice = undefined;
|
|
412
|
+
if (!s.peers[peer] || s.peers[peer].state === "offline") notify(s, `${peer} is no longer attached`, now);
|
|
413
|
+
else if (permissionModeRefusal(peer, s.peers[peer].permissionMode)) notify(s, permissionModeRefusal(peer, s.peers[peer].permissionMode)!, now);
|
|
414
|
+
else if (mode === "never-ask") s.confirm = { type: "command", args: ["permission", peer, mode] };
|
|
415
|
+
else effects.push({ type: "command", args: ["permission", peer, mode] });
|
|
416
|
+
}
|
|
417
|
+
return done();
|
|
418
|
+
}
|
|
276
419
|
if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
|
|
277
420
|
if (s.editing || s.input) {
|
|
278
421
|
if (key === "\x1b") { s.input = ""; s.editing = false; }
|
|
@@ -285,7 +428,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
285
428
|
const input = s.input; s.input = ""; s.editing = false;
|
|
286
429
|
if (!input.trim()) return done();
|
|
287
430
|
try {
|
|
288
|
-
const
|
|
431
|
+
const parsed = parseConsoleCommand(input);
|
|
432
|
+
const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
|
|
289
433
|
if (args[0] === "queue" && args[1] === "resolve") {
|
|
290
434
|
const reason = args.indexOf("--reason");
|
|
291
435
|
if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
|
|
@@ -351,6 +495,14 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
351
495
|
return done();
|
|
352
496
|
}
|
|
353
497
|
if (s.mode === "panels" && item !== undefined && item !== null) {
|
|
498
|
+
if (s.panel === 1 && key === "m") {
|
|
499
|
+
const peer = String(item);
|
|
500
|
+
const info = s.peers[peer];
|
|
501
|
+
if (!info || info.state === "offline") notify(s, `${peer} is not attached`, now);
|
|
502
|
+
else if (permissionModeRefusal(peer, info.permissionMode)) notify(s, permissionModeRefusal(peer, info.permissionMode)!, now);
|
|
503
|
+
else s.modeChoice = peer;
|
|
504
|
+
return done();
|
|
505
|
+
}
|
|
354
506
|
const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
|
|
355
507
|
s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
|
|
356
508
|
s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
|
|
@@ -373,12 +525,13 @@ const NOTICE_MS = 10_000;
|
|
|
373
525
|
const TABLES: Record<number, string[]> = {
|
|
374
526
|
1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
375
527
|
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
376
|
-
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE"],
|
|
528
|
+
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
377
529
|
4: ["ID", "PEER", "STATE", "REV", "AGE"],
|
|
378
530
|
};
|
|
531
|
+
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
379
532
|
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
380
533
|
/** 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[] {
|
|
534
|
+
function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
|
|
382
535
|
const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
|
|
383
536
|
if (s.panel === 1) {
|
|
384
537
|
const b = s.budget[row.id];
|
|
@@ -386,6 +539,7 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number): Span[
|
|
|
386
539
|
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
387
540
|
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
388
541
|
return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.state, stateTone(row.state)),
|
|
542
|
+
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
389
543
|
span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
390
544
|
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
391
545
|
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
@@ -397,8 +551,10 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number): Span[
|
|
|
397
551
|
const last = row.history?.at(-1);
|
|
398
552
|
const at = last?.at ?? row.updated ?? row.created;
|
|
399
553
|
const failed = last?.event === "check failed" || last?.event === "failed";
|
|
400
|
-
|
|
401
|
-
|
|
554
|
+
const stage = stages?.get(row.id);
|
|
555
|
+
const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
556
|
+
return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
|
|
557
|
+
span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(meter, stage?.changes ? "failure" : stateTone(row.state))];
|
|
402
558
|
}
|
|
403
559
|
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
560
|
const [header, ...body] = String(row.text).split("\n");
|
|
@@ -413,22 +569,33 @@ function table(head: string[], rows: Span[][], columns: number): Span[][] {
|
|
|
413
569
|
const cap = Math.max(24, Math.floor(columns / 3));
|
|
414
570
|
const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
|
|
415
571
|
const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
|
|
572
|
+
const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
|
|
573
|
+
if (head.includes("STAGE")) {
|
|
574
|
+
// Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
|
|
575
|
+
const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
|
|
576
|
+
for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
|
|
577
|
+
const metadata = head.indexOf(key);
|
|
578
|
+
while (room() < 7 && widths[metadata]! > head[metadata]!.length) widths[metadata] = widths[metadata]! - 1;
|
|
579
|
+
}
|
|
580
|
+
widths[flexible] = Math.max(0, room());
|
|
581
|
+
}
|
|
416
582
|
return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
|
|
417
|
-
if (i === head.length - 1) return cell;
|
|
583
|
+
if (i === head.length - 1 && flexible === i) return cell;
|
|
418
584
|
const text = fit(cell.text, widths[i]!);
|
|
419
|
-
return { ...cell, text: text + " ".repeat(widths[i]! - Bun.stringWidth(text) + 2) };
|
|
585
|
+
return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
|
|
420
586
|
}));
|
|
421
587
|
}
|
|
422
588
|
/** The footer's key hint: only keys that act in this mode, panel and state. */
|
|
423
|
-
function hint(s: ConsoleState): string {
|
|
589
|
+
function hint(s: ConsoleState, columns: number): string {
|
|
424
590
|
const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
|
|
425
591
|
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
592
|
if (s.help) return "? or Esc close q quit";
|
|
427
593
|
const approvals = s.panel === 2 ? pending : [];
|
|
428
594
|
if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
|
|
429
595
|
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
|
-
|
|
596
|
+
const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume", "m permission mode"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
|
|
597
|
+
const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
|
|
598
|
+
return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
|
|
432
599
|
}
|
|
433
600
|
/** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
|
|
434
601
|
function header(s: ConsoleState, columns: number): Span[] {
|
|
@@ -446,45 +613,76 @@ function header(s: ConsoleState, columns: number): Span[] {
|
|
|
446
613
|
const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
|
|
447
614
|
export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
|
|
448
615
|
const permission = selectedApproval(s);
|
|
449
|
-
let prompt = s.editing ? `: ${s.input}` : hint(s);
|
|
616
|
+
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
450
617
|
if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
451
618
|
if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
|
|
619
|
+
if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
|
|
452
620
|
const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
|
|
453
621
|
if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
|
|
454
622
|
const summary: Span[] = [];
|
|
455
623
|
for (const [id, p] of Object.entries(s.peers)) {
|
|
456
624
|
if (summary.length) summary.push(span(" "));
|
|
457
625
|
summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
|
|
626
|
+
if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
|
|
458
627
|
if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
|
|
459
628
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
460
629
|
if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
|
|
461
630
|
}
|
|
462
631
|
const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
|
|
463
|
-
if (quotas) summary.push(span(
|
|
632
|
+
if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
|
|
464
633
|
const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
|
|
465
634
|
if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
|
|
466
635
|
else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
|
|
467
|
-
|
|
468
|
-
|
|
636
|
+
const noticeShown = !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
|
|
637
|
+
if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
|
|
638
|
+
const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
|
|
639
|
+
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
640
|
+
if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
|
|
641
|
+
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
|
|
642
|
+
fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
|
|
643
|
+
fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
|
|
469
644
|
const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
|
|
470
|
-
if (s.mode === "stream")
|
|
645
|
+
if (s.mode === "stream") {
|
|
646
|
+
const counts = s.taskCounts;
|
|
647
|
+
const total = counts ? Object.values(counts).reduce((sum, value) => sum + value, 0) : 0;
|
|
648
|
+
const taskCount = span(counts ? `tasks ${counts.approved ?? 0}/${total} approved` : "tasks loading...", counts ? "success" : "attention");
|
|
649
|
+
if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
|
|
650
|
+
footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
|
|
651
|
+
}
|
|
652
|
+
return [rule, ...footer];
|
|
653
|
+
}
|
|
654
|
+
const progress = taskProgress(s.tasks);
|
|
471
655
|
const height = rows - 6; // header, rule, body, rule, three footer lines
|
|
472
656
|
const lines: Span[][] = [];
|
|
473
|
-
if (s.
|
|
657
|
+
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
658
|
+
else if (s.help) lines.push(...keyTable(columns));
|
|
474
659
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
475
660
|
const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
476
661
|
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
477
662
|
}
|
|
478
663
|
else {
|
|
479
|
-
const data = panelRows(s); const head = TABLES[s.panel]; const at = selection(s);
|
|
664
|
+
const data = panelRows(s); const head = s.panel === 1 && showPermissionModes(s) ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : TABLES[s.panel]; const at = selection(s);
|
|
480
665
|
// Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
|
|
481
666
|
// shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
|
|
482
667
|
const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
|
|
483
668
|
const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
|
|
484
669
|
if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
|
|
485
670
|
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
|
|
671
|
+
const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
|
|
672
|
+
const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
673
|
+
const rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now, stages)), columns) : undefined;
|
|
674
|
+
if (s.panel === 3) {
|
|
675
|
+
const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
|
|
676
|
+
const main = s.tasksKnown === false && !progress.total ? "tasks loading..." : `${progress.counts.approved}/${progress.total} approved [${"#".repeat(done)}${".".repeat(20 - done)}] ${progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0}%`;
|
|
677
|
+
const known = s.tasksKnown !== false || progress.total > 0;
|
|
678
|
+
const parts = [span(main, known ? "success" : "attention")];
|
|
679
|
+
for (const [label, value, tone] of [["changes", progress.counts.changes_requested, "failure"], ["review", progress.counts.in_review, "attention"], ["waiting", progress.counts.waiting, "attention"], ["in progress", progress.counts.in_progress, undefined], ["proposed", progress.counts.proposed, undefined], ...(progress.counts.unknown ? [["unknown", progress.counts.unknown, "attention"] as const] : [])] as const) {
|
|
680
|
+
const extra = ` ${label} ${value}`;
|
|
681
|
+
if (!known || Bun.stringWidth(parts.map(p => p.text).join("") + extra) > columns) break;
|
|
682
|
+
parts.push(span(extra, tone));
|
|
683
|
+
}
|
|
684
|
+
lines.push(fitLine(parts, columns));
|
|
685
|
+
}
|
|
488
686
|
lines.push(fitLine([span(" "), ...(rowsOf?.[0] ?? [span(`peer ${s.peerFilter ?? "all"} kind ${s.kindFilter ?? "all"}`, "info")])], columns));
|
|
489
687
|
for (let i = start; i < Math.min(data.length, start + capacity); i++) {
|
|
490
688
|
const selected = i === at;
|
package/src/cli/console.ts
CHANGED
|
@@ -4,7 +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 { approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, terminalText, wrap } from "./console-state.ts";
|
|
7
|
+
import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap } from "./console-state.ts";
|
|
8
8
|
import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
|
|
9
9
|
import type { BusEvent } from "../hub/bus.ts";
|
|
10
10
|
|
|
@@ -76,7 +76,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
76
76
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
77
77
|
const notice = (text: string) => notify(state, text, Date.now());
|
|
78
78
|
const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
|
|
79
|
-
const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => wrap(text, columns).map(line => [{ text: line
|
|
79
|
+
const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]));
|
|
80
80
|
const writeStream = (event: ConsoleEvent) => {
|
|
81
81
|
terminal.write(`\x1b[${rows - 4};1H`); // the scroll region ends above the rule and the three footer lines
|
|
82
82
|
for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
|
|
@@ -86,7 +86,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
86
86
|
if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
|
|
87
87
|
if (plain) {
|
|
88
88
|
const [header, ...body] = terminalText(event.text).split("\n");
|
|
89
|
-
terminal.write(paint([
|
|
89
|
+
terminal.write(paint([...(color ? streamTokens(header ?? "", event.tone, event.kind) : [{ text: header ?? "" }]), ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
|
|
90
90
|
}
|
|
91
91
|
else if (state.mode === "stream") writeStream(event);
|
|
92
92
|
else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
|
|
@@ -130,11 +130,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
130
130
|
]);
|
|
131
131
|
if (!active) return;
|
|
132
132
|
const [status, budget, tasks, queue, ready] = replies;
|
|
133
|
+
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
133
134
|
if (status.status?.peers) state.peers = status.status.peers;
|
|
135
|
+
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" });
|
|
134
136
|
if (budget.budget) state.budget = budget.budget;
|
|
135
|
-
if (tasks?.ok
|
|
137
|
+
if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
|
|
136
138
|
const readyIds = new Set(ready?.ok ? JSON.parse(ready.text).map((task: any) => task.id) : []);
|
|
137
139
|
state.tasks = parsed.map(task => ({ ...task, ready: readyIds.has(task.id) }));
|
|
140
|
+
state.tasksKnown = true;
|
|
138
141
|
} }
|
|
139
142
|
if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
|
|
140
143
|
const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
|
|
@@ -156,6 +159,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
156
159
|
});
|
|
157
160
|
const effect = async (action: ConsoleEffect) => {
|
|
158
161
|
if (action.type === "exit") return stop();
|
|
162
|
+
if (action.type === "permission_default") {
|
|
163
|
+
const result = await client.request({ t: "permission_default", peer: action.peer, confirmed: action.confirmed }, 35_000);
|
|
164
|
+
if (!active) return;
|
|
165
|
+
if (result.ok === false) {
|
|
166
|
+
notice(String(result.error ?? "permission default refused"));
|
|
167
|
+
} else stream({ text: ` ${action.peer} never-ask default ${action.confirmed ? "enabled" : "cancelled for this hub"}`, kind: "permission_default", peer: action.peer, tone: "attention" });
|
|
168
|
+
await refresh(); draw(); return;
|
|
169
|
+
}
|
|
159
170
|
if (action.type === "permit") {
|
|
160
171
|
client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
|
|
161
172
|
if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
|
|
@@ -221,6 +232,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
221
232
|
// Under the modal key table it does nothing at all.
|
|
222
233
|
if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
|
|
223
234
|
if (state.help && state.mode === "panels") return;
|
|
235
|
+
if (state.confirm?.type === "permission_default") { draw(); return; }
|
|
224
236
|
state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
|
|
225
237
|
state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
|
|
226
238
|
} else handle(text);
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// Claude
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
|
|
3
|
+
// PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
|
|
4
|
+
// A failed or timed-out hub decides nothing, leaving Claude's native permission rules in effect.
|
|
5
|
+
import { relative, resolve, isAbsolute } from "node:path";
|
|
6
|
+
import { realPath } from "../hub/project.ts";
|
|
7
|
+
import { isAgentConfigPath, isPermissionMode, hardLinked, isHubWritePath } from "../hub/permission-mode.ts";
|
|
6
8
|
import { ControlClient } from "../hub/control-client.ts";
|
|
7
9
|
|
|
8
10
|
/** A library call may target another hub; only the managed command hook inherits that target's native identity. */
|
|
9
|
-
export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string } {
|
|
11
|
+
export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.ProcessEnv = process.env): { nativeInstanceId?: string; nativeLaunchId?: string; hookPurpose?: string } {
|
|
10
12
|
if (env.AGENTHUB_STATE_DIR !== stateDir || env.AGENTHUB_PEER_ID !== peer) return {};
|
|
11
|
-
return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}) };
|
|
13
|
+
return { ...(env.AGENTHUB_INSTANCE_ID ? { nativeInstanceId: env.AGENTHUB_INSTANCE_ID } : {}), ...(env.AGENTHUB_LAUNCH_ID ? { nativeLaunchId: env.AGENTHUB_LAUNCH_ID } : {}), ...(["facts", "idle", "facts-and-idle", "permission"].includes(env.AGENTHUB_HOOK_PURPOSE ?? "") ? { hookPurpose: env.AGENTHUB_HOOK_PURPOSE } : {}) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only resolving project paths outside the hub/native configuration directories receive a file grant. */
|
|
17
|
+
export function projectFileTool(tool: unknown, input: unknown, projectRoot: unknown): boolean {
|
|
18
|
+
if (typeof projectRoot !== "string" || !isAbsolute(projectRoot) || !input || typeof input !== "object" || Array.isArray(input)) return false;
|
|
19
|
+
const args = input as Record<string, unknown>;
|
|
20
|
+
const key = ["Read", "Edit", "Write", "MultiEdit"].includes(String(tool)) ? "file_path" : tool === "NotebookEdit" ? "notebook_path" : ["LS", "Glob", "Grep"].includes(String(tool)) ? "path" : undefined;
|
|
21
|
+
if (!key) return false;
|
|
22
|
+
const optional = tool === "Glob" || tool === "Grep";
|
|
23
|
+
if (args[key] !== undefined && (typeof args[key] !== "string" || !args[key])) return false;
|
|
24
|
+
if (!optional && args[key] === undefined) return false;
|
|
25
|
+
try {
|
|
26
|
+
const root = realPath(projectRoot);
|
|
27
|
+
const protectedPath = (path: string) => isAgentConfigPath(path) || isHubWritePath(path);
|
|
28
|
+
const safe = (path: string) => {
|
|
29
|
+
const requested = resolve(root, path), lexical = relative(root, requested);
|
|
30
|
+
if (protectedPath(lexical)) return false;
|
|
31
|
+
const real = realPath(requested), canonical = relative(root, real);
|
|
32
|
+
// A hard link is another name for a file that may sit in a protected directory.
|
|
33
|
+
return canonical !== ".." && !canonical.startsWith("../") && !isAbsolute(canonical) && !protectedPath(canonical) && !hardLinked(real);
|
|
34
|
+
};
|
|
35
|
+
const target = typeof args[key] === "string" ? args[key] as string : root;
|
|
36
|
+
if (target.split(/[\\/]/).includes("..") || !safe(target)) return false;
|
|
37
|
+
if (tool === "Grep" && args.glob !== undefined) {
|
|
38
|
+
if (typeof args.glob !== "string" || !args.glob || args.glob.split(/[\\/]/).includes("..") || protectedPath(args.glob)) return false;
|
|
39
|
+
if (/[?*[{]/.test(args.glob) || !safe(resolve(root, target, args.glob))) return false;
|
|
40
|
+
}
|
|
41
|
+
if (tool === "Glob") {
|
|
42
|
+
if (typeof args.pattern !== "string" || !args.pattern) return false;
|
|
43
|
+
if (args.pattern.split(/[\\/]/).includes("..") || protectedPath(args.pattern)) return false;
|
|
44
|
+
// Wildcard expansions can enter symlinked or protected directories; their exact targets are unknown here.
|
|
45
|
+
if (/[?*[{]/.test(args.pattern) || !safe(resolve(root, target, args.pattern))) return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
} catch { return false; }
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
/** The hook's stdout for one Claude Code hook input, or undefined for none. */
|
|
@@ -28,8 +65,13 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
28
65
|
...(typeof input.transcript_path === "string" ? { transcriptPath: input.transcript_path } : {}),
|
|
29
66
|
startedMs: performance.now(), // this process's own start-up and connect time, for the latency record
|
|
30
67
|
}, timeoutMs);
|
|
31
|
-
if (phase !== "pre" || !res?.ok
|
|
32
|
-
|
|
68
|
+
if (phase !== "pre" || !res?.ok) return undefined;
|
|
69
|
+
const permission = isPermissionMode(res.permission) ? res.permission : "ask";
|
|
70
|
+
const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot);
|
|
71
|
+
const allow = input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
|
|
72
|
+
const text = typeof res.text === "string" && res.text ? res.text : undefined;
|
|
73
|
+
if (!allow && !text) return undefined;
|
|
74
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(allow ? { permissionDecision: "allow" } : {}) } });
|
|
33
75
|
} finally {
|
|
34
76
|
hub.close();
|
|
35
77
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -43,6 +43,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
43
43
|
["Messages and approvals", [
|
|
44
44
|
["ahub say [@peer ...] <text>", "send as the console user (no @peer = broadcast); delivered at once, start the text with [STATUS] to let it batch or [FYI] for the record only"],
|
|
45
45
|
["ahub tail", "live stream of messages, states and permission requests"],
|
|
46
|
+
["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
|
|
46
47
|
["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
|
|
47
48
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
48
49
|
["ahub queue list [--peer <id>] [--json]", "inspect durable deliveries"],
|
|
@@ -54,6 +55,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
54
55
|
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
|
|
55
56
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
56
57
|
["ahub task assign <id> <peer>", "give a task to a peer yourself; a task that still waits is reserved for that peer instead"],
|
|
58
|
+
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
57
59
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
58
60
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
59
61
|
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
@@ -71,6 +73,14 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
71
73
|
["ahub undo <turn> [--yes] [--context]", "put back the files a turn changed; refuses files changed since. Without --yes it only lists them; --context also drops a Codex turn from its conversation"],
|
|
72
74
|
["ahub report [--since 7d|<iso>] [--by task] [--json]", "turns, tokens, messages, overlaps and task events per period"],
|
|
73
75
|
["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
|
|
76
|
+
["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
|
|
77
|
+
["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
|
|
78
|
+
["ahub research backfill", "build this project's research records once from events.jsonl"],
|
|
79
|
+
["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree)"],
|
|
80
|
+
["ahub bench list|status", "benchmark runs on this machine / the one running now"],
|
|
81
|
+
["ahub bench report <run> [--json]", "pass rate, first pass, rework, tokens and wall time per task and overall"],
|
|
82
|
+
["ahub bench compare <run|arm>... [--suite <name>] [--mixed] [--json]", "compare arms of one suite with 95% bootstrap intervals; inconclusive below 5 counted attempts per arm"],
|
|
83
|
+
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
74
84
|
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
75
85
|
]],
|
|
76
86
|
["Hooks", [
|