@staix/agent-hub 0.12.23 → 0.12.24
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 +98 -0
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +2 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +651 -14
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +1 -1
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +282 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +910 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +892 -87
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +842 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/cli/console-state.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attention.ts";
|
|
2
|
+
import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
|
|
3
|
+
import { PEER_PALETTE } from "../ui/peer-palette.ts";
|
|
1
4
|
import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
|
|
2
5
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
3
6
|
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
@@ -10,6 +13,11 @@ export interface Approval {
|
|
|
10
13
|
export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
|
|
11
14
|
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
12
15
|
export interface ConsoleState {
|
|
16
|
+
/** Older embedding callers omit choices and retain the original STAI/off toggle. */
|
|
17
|
+
petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
|
|
18
|
+
attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
|
|
19
|
+
attentionFingerprint?: string;
|
|
20
|
+
petEnabled?: boolean; petBlink?: boolean; attentionMessages?: { id?: string; peer?: string; important: boolean; since?: number }[]; storageBlocked?: boolean; recoveryNeed?: { id?: string; running: boolean; since?: number }; attentionSelection?: number;
|
|
13
21
|
mode: "stream" | "panels"; panel: number; selection: number;
|
|
14
22
|
/** The selected request, by id: a, d and v act on it alone, and it is cleared, never moved, when the request closes. */
|
|
15
23
|
approvalId?: string;
|
|
@@ -77,9 +85,9 @@ export function terminalText(value: unknown): string {
|
|
|
77
85
|
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
|
|
78
86
|
export interface Span { text: string; tone?: Tone }
|
|
79
87
|
export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
|
|
80
|
-
info:
|
|
81
|
-
peerClaude:
|
|
82
|
-
taskKeyword:
|
|
88
|
+
info: PEER_PALETTE.hub.ansi, strong: "\x1b[1;36m", success: PEER_PALETTE.local.ansi, attention: PEER_PALETTE.pi.ansi, failure: "\x1b[31m", muted: PEER_PALETTE.other.ansi,
|
|
89
|
+
peerClaude: PEER_PALETTE.claude.ansi, peerCodex: PEER_PALETTE.codex.ansi,
|
|
90
|
+
taskKeyword: PEER_PALETTE.kimi.ansi, number: "\x1b[1m", issueRef: "\x1b[4m", taskRef: "\x1b[4;35m",
|
|
83
91
|
});
|
|
84
92
|
export function paint(line: Span[], color: boolean): string {
|
|
85
93
|
return line.map(span => {
|
|
@@ -179,7 +187,10 @@ export function stateTone(state: string): Tone | undefined {
|
|
|
179
187
|
}
|
|
180
188
|
const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
|
|
181
189
|
/** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
|
|
182
|
-
const flat = (value: unknown) =>
|
|
190
|
+
const flat = (value: unknown) => {
|
|
191
|
+
const text = terminalText(value), line = text.replace(/[\n\t]/g, " ");
|
|
192
|
+
return text.includes("\t") ? terminalText(line) : line;
|
|
193
|
+
};
|
|
183
194
|
/**
|
|
184
195
|
* Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
|
|
185
196
|
* prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
|
|
@@ -313,9 +324,9 @@ const KEYS: [string, string[]][] = [
|
|
|
313
324
|
["Events", ["f peer filter", "g kind filter"]],
|
|
314
325
|
];
|
|
315
326
|
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
316
|
-
export function keyTable(columns: number): Span[][] {
|
|
327
|
+
export function keyTable(columns: number, attention = false, characters = false): Span[][] {
|
|
317
328
|
const lines: Span[][] = []; const pad = 12;
|
|
318
|
-
for (const [group, items] of KEYS) {
|
|
329
|
+
for (const [group, items] of [...KEYS, ...(attention ? [[characters ? "Pet" : "Attention", ["n next need", characters ? "P next character/off" : "P STAI/off"]] as [string, string[]]] : [])]) {
|
|
319
330
|
let text = ""; let head = group;
|
|
320
331
|
const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
|
|
321
332
|
for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
|
|
@@ -341,7 +352,7 @@ function bound(s: ConsoleState): ConsoleState {
|
|
|
341
352
|
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
342
353
|
return s;
|
|
343
354
|
}
|
|
344
|
-
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
|
|
355
|
+
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report"]);
|
|
345
356
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
346
357
|
export function parseConsoleCommand(input: string): string[] {
|
|
347
358
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -377,6 +388,7 @@ export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id
|
|
|
377
388
|
/** The selected row: the Approvals panel's is the selected request (-1 when none is). */
|
|
378
389
|
const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
|
|
379
390
|
export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
|
|
391
|
+
const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
|
|
380
392
|
// A notice answers the key before this one; one that pruning raises now stays.
|
|
381
393
|
let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
|
|
382
394
|
const done = () => ({ state: bound(s), effects });
|
|
@@ -438,7 +450,47 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
438
450
|
} else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
|
|
439
451
|
return done();
|
|
440
452
|
}
|
|
453
|
+
if ((key === "n" || key === "P") && attentionKeyBlocked) return done();
|
|
441
454
|
if (key === "q") { effects.push({ type: "exit" }); return done(); }
|
|
455
|
+
if (key === "P" && !s.optionChoice && !s.help) {
|
|
456
|
+
const choices = s.petChoices;
|
|
457
|
+
if (!choices?.length) s.petEnabled = !s.petEnabled;
|
|
458
|
+
else if (!s.petEnabled) { s.petEnabled = true; s.petCharacter = choices[0]; }
|
|
459
|
+
else { const next = choices.indexOf(s.petCharacter ?? "stai") + 1; s.petEnabled = next < choices.length; if (s.petEnabled) s.petCharacter = choices[next]; }
|
|
460
|
+
return done();
|
|
461
|
+
}
|
|
462
|
+
if (key === "n" && s.petEnabled && !s.optionChoice && !s.help) {
|
|
463
|
+
const items = consoleNeeds(s, now).items;
|
|
464
|
+
if (!items.length) return done();
|
|
465
|
+
const fingerprint = JSON.stringify(items);
|
|
466
|
+
const prior = s.attentionFingerprint === fingerprint ? s.attentionSelection ?? -1 : -1;
|
|
467
|
+
const index = (prior + 1) % items.length;
|
|
468
|
+
s.attentionFingerprint = fingerprint;
|
|
469
|
+
s.attentionSelection = index;
|
|
470
|
+
const item = items[index]!;
|
|
471
|
+
let navigated = false;
|
|
472
|
+
if (item.kind === "approval") {
|
|
473
|
+
s.approvalId = String(item.id);
|
|
474
|
+
if (s.mode === "panels") { s.panel = 2; s.selection = s.approvals.findIndex(row => row.id === item.id); }
|
|
475
|
+
navigated = true;
|
|
476
|
+
} else if (item.kind === "delivery" && s.mode === "panels") {
|
|
477
|
+
const rows = s.queue.filter(row => row.state === "needs_review" || row.state === "queued");
|
|
478
|
+
const selected = item.id === undefined ? -1 : rows.findIndex(row => row.id === item.id);
|
|
479
|
+
if (selected >= 0) { s.panel = 4; s.selection = selected; navigated = true; }
|
|
480
|
+
} else if (item.kind === "review" && s.mode === "panels") {
|
|
481
|
+
const rows = panelRows({ ...s, panel: 3 });
|
|
482
|
+
const selected = rows.findIndex(row => row.id === item.id);
|
|
483
|
+
if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
|
|
484
|
+
}
|
|
485
|
+
if (navigated) { s.detail = undefined; s.requestDetail = undefined; }
|
|
486
|
+
else {
|
|
487
|
+
const command = attentionCommand(item);
|
|
488
|
+
const text = `${item.kind}${item.peer ? ` ${item.peer}` : ""}${item.id ? ` ${item.id}` : ""}: ${command}`;
|
|
489
|
+
if (s.mode === "panels") notify(s, text, now, "attention");
|
|
490
|
+
effects.push({ type: "print", text, kind: "attention", tone: "attention" });
|
|
491
|
+
}
|
|
492
|
+
return done();
|
|
493
|
+
}
|
|
442
494
|
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
443
495
|
if (key === ":") { s.editing = true; return done(); }
|
|
444
496
|
const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
@@ -523,6 +575,9 @@ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now =
|
|
|
523
575
|
return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
|
|
524
576
|
}
|
|
525
577
|
const NOTICE_MS = 10_000;
|
|
578
|
+
export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now = Date.now()): boolean {
|
|
579
|
+
return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
|
|
580
|
+
}
|
|
526
581
|
export const TABLES: Record<number, string[]> = {
|
|
527
582
|
1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
528
583
|
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
@@ -539,9 +594,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
539
594
|
const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
|
|
540
595
|
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
541
596
|
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
542
|
-
return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.state, stateTone(row.state)),
|
|
597
|
+
return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
|
|
543
598
|
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
544
|
-
span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
599
|
+
span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
545
600
|
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
546
601
|
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
547
602
|
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(", ") : "-"),
|
|
@@ -555,9 +610,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
555
610
|
const stage = stages?.get(row.id);
|
|
556
611
|
const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
557
612
|
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)),
|
|
558
|
-
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))];
|
|
613
|
+
span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.profileClass ? `${row.profileClass} (base ${row.class})` : row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(row.lifecycle ? `${meter} ${row.lifecycle.workflow ? row.lifecycle.workflow + " " : ""}SDLC ${row.lifecycle.stage}${row.lifecycle.definition ? ` (base ${row.lifecycle.definition.base}) / next: ${row.lifecycle.actor} / entry: ${row.lifecycle.entry?.length ? "blocked" : "ready"} / exit: ${row.lifecycle.exit?.length ? "blocked" : "ready"}` : ""}: ${row.lifecycle.next}` : meter, stage?.changes ? "failure" : stateTone(row.state))];
|
|
559
614
|
}
|
|
560
|
-
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")];
|
|
615
|
+
if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
|
|
561
616
|
const [header, ...body] = String(row.text).split("\n");
|
|
562
617
|
// The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
|
|
563
618
|
// text the stream tokenizes, so an event reads the same in both places (#309); body lines stay plain, as in the
|
|
@@ -617,6 +672,35 @@ function header(s: ConsoleState, columns: number): Span[] {
|
|
|
617
672
|
}
|
|
618
673
|
/** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
|
|
619
674
|
const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
|
|
675
|
+
export function consoleFooterRows(s: ConsoleState, columns: number, rows: number): number {
|
|
676
|
+
return s.petEnabled ? petFooterRows(petSize(s.petSize, columns, rows)) : 4;
|
|
677
|
+
}
|
|
678
|
+
function decoratePetFooter(s: ConsoleState, columns: number, rows: number, now: number, rule: Span[], footer: Span[][], noticeShown: boolean): Span[][] {
|
|
679
|
+
if (!s.petEnabled) return [rule, ...footer];
|
|
680
|
+
const value = consoleNeeds(s, now), character = s.petCharacter ?? "stai", size = petSize(s.petSize, columns, rows);
|
|
681
|
+
const tone: Tone = value.state === "blocked" ? "failure" : value.act ? "attention" : "muted";
|
|
682
|
+
if (size === "line") {
|
|
683
|
+
const text = characterAttentionLine(value, character, now, s.petBlink, true, s.petGlyphs);
|
|
684
|
+
return [Bun.stringWidth(text) + 4 <= columns ? [span("-".repeat(columns - Bun.stringWidth(text) - 2) + " ", "muted"), span(text.slice(0, 5)), span(text.slice(5), tone), span("-", "muted")] : rule, ...footer];
|
|
685
|
+
}
|
|
686
|
+
const pose: PetPose = s.petBlink && value.items.some(item => item.kind === "approval") && Math.floor(now / 1000) % 2 ? "waitingBlink" : value.state;
|
|
687
|
+
const frame = petFrame(character, pose, size, s.petGlyphs), width = size === "mini" ? 9 : 12, height = size === "mini" ? 3 : 8;
|
|
688
|
+
const available = columns - width - 1, caption = characterAttentionLine(value, character, now, false).slice(6);
|
|
689
|
+
const left: Span[][] = [[span(caption, tone)], footer[0]!, footer[1]!];
|
|
690
|
+
// Notices and source paths stay in their existing views, never beside an expanded character.
|
|
691
|
+
if (noticeShown || s.confirm?.type === "permission_default") left[2] = [span(`${s.approvals.length} approvals / ${value.act} action / ${value.watch} watch`, tone)];
|
|
692
|
+
for (let index = 0; index < height - 3; index++) {
|
|
693
|
+
const item = value.items[index];
|
|
694
|
+
left.push(item ? [span([item.kind, item.peer, item.id].filter(part => part !== undefined).join(" "), item.level === "act" ? "attention" : "muted")] : []);
|
|
695
|
+
}
|
|
696
|
+
const result = left.map((line, index) => {
|
|
697
|
+
const fitted = fitLine(line, available), text = fitted.map(cell => cell.text).join("");
|
|
698
|
+
const body = frame[index] ?? " ".repeat(width), accent = CHARACTERS[character].accent?.[size];
|
|
699
|
+
const bodySpans = accent?.row === index ? [span(body.slice(0, accent.start)), span(body.slice(accent.start, accent.start + accent.length), "attention"), span(body.slice(accent.start + accent.length))] : [span(body)];
|
|
700
|
+
return [...fitted, span(" ".repeat(Math.max(0, available - Bun.stringWidth(text)) + 1)), ...bodySpans];
|
|
701
|
+
});
|
|
702
|
+
return [...result, footer[2]!]; // The prompt line remains full-width and in its original terminal row.
|
|
703
|
+
}
|
|
620
704
|
export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
|
|
621
705
|
const permission = selectedApproval(s);
|
|
622
706
|
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
@@ -632,14 +716,14 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
632
716
|
if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
|
|
633
717
|
if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
|
|
634
718
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
635
|
-
if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
|
|
719
|
+
if (p.toolsOnly) summary.push(span(id === "kimi" ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
|
|
636
720
|
}
|
|
637
721
|
const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
|
|
638
722
|
if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
|
|
639
723
|
const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
|
|
640
724
|
if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
|
|
641
725
|
else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
|
|
642
|
-
const noticeShown =
|
|
726
|
+
const noticeShown = activeNotice(s, now);
|
|
643
727
|
if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
|
|
644
728
|
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;
|
|
645
729
|
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
@@ -655,13 +739,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
655
739
|
if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
|
|
656
740
|
footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
|
|
657
741
|
}
|
|
658
|
-
return
|
|
742
|
+
return decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown);
|
|
659
743
|
}
|
|
660
744
|
const progress = taskProgress(s.tasks);
|
|
661
|
-
const
|
|
745
|
+
const bodyHeight = rows - 2 - consoleFooterRows(s, columns, rows);
|
|
746
|
+
const noticeLines = noticeShown && s.petEnabled && petSize(s.petSize, columns, rows) !== "line" ? wrap(`Notice: ${s.notice}`, columns).slice(0, Math.max(0, bodyHeight - 6)).map(text => [span(text, s.noticeTone ?? "failure")]) : [];
|
|
747
|
+
const height = bodyHeight - noticeLines.length;
|
|
662
748
|
const lines: Span[][] = [];
|
|
663
749
|
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());
|
|
664
|
-
else if (s.help) lines.push(...keyTable(columns));
|
|
750
|
+
else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
|
|
665
751
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
666
752
|
const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
667
753
|
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
@@ -702,5 +788,5 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
702
788
|
}
|
|
703
789
|
}
|
|
704
790
|
const body = lines.slice(0, height); while (body.length < height) body.push([]);
|
|
705
|
-
return [header(s, columns), rule, ...body, rule,
|
|
791
|
+
return [header(s, columns), rule, ...noticeLines, ...body, ...decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown)];
|
|
706
792
|
}
|
package/src/cli/console.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { renderTailEvent } from "./tail-render.ts";
|
|
|
7
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
|
+
import { consoleNeeds, attentionFacts } from "./attention.ts";
|
|
11
|
+
import { loadPetConfig } from "./pet-config.ts";
|
|
12
|
+
import { CHARACTER_IDS, petGlyphs, petSize } from "../pet/frame.ts";
|
|
13
|
+
import { activeNotice, consoleFooterRows } from "./console-state.ts";
|
|
14
|
+
import { characterAttentionLine } from "./attention.ts";
|
|
10
15
|
|
|
11
16
|
export interface ConsoleTerminal {
|
|
12
17
|
columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
|
|
@@ -22,6 +27,7 @@ export interface ConsoleOptions {
|
|
|
22
27
|
cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
|
|
23
28
|
terminal?: ConsoleTerminal;
|
|
24
29
|
color?: boolean;
|
|
30
|
+
noPet?: boolean;
|
|
25
31
|
/** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
|
|
26
32
|
runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
|
|
27
33
|
pollMs?: number;
|
|
@@ -67,6 +73,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
67
73
|
const { client } = options; const terminal = options.terminal ?? nativeTerminal();
|
|
68
74
|
const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
69
75
|
let state = initialConsoleState(!!options.panels); state.project = basename(options.cwd);
|
|
76
|
+
const pet = loadPetConfig(options.cwd);
|
|
77
|
+
state.petEnabled = pet.enabled && !options.noPet; state.petBlink = pet.blink;
|
|
78
|
+
state.petChoices = CHARACTER_IDS; state.petCharacter = pet.character ?? "stai";
|
|
79
|
+
state.petSize = pet.size; state.petGlyphs = petGlyphs(pet.glyphs);
|
|
80
|
+
let lastAct = 0; let lastBell = -Infinity;
|
|
70
81
|
let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
|
|
71
82
|
let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
|
|
72
83
|
let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
|
|
@@ -74,6 +85,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
74
85
|
const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
|
|
75
86
|
let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
|
|
76
87
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
88
|
+
let priorStreamFooterStart: number | undefined;
|
|
89
|
+
let lastExpandedNotice = "";
|
|
77
90
|
const notice = (text: string) => notify(state, text, Date.now());
|
|
78
91
|
const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
|
|
79
92
|
const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
|
|
@@ -82,7 +95,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
82
95
|
return index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]);
|
|
83
96
|
});
|
|
84
97
|
const writeStream = (event: ConsoleEvent) => {
|
|
85
|
-
terminal.write(`\x1b[${rows -
|
|
98
|
+
terminal.write(`\x1b[${rows - consoleFooterRows(state, columns, rows)};1H`); // the scroll region ends above the rule and the three footer lines
|
|
86
99
|
for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
|
|
87
100
|
};
|
|
88
101
|
/** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
|
|
@@ -97,15 +110,34 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
97
110
|
else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
|
|
98
111
|
};
|
|
99
112
|
const draw = () => {
|
|
100
|
-
if (!active
|
|
113
|
+
if (!active) return;
|
|
114
|
+
if (state.petEnabled) {
|
|
115
|
+
const attention = consoleNeeds(state, Date.now());
|
|
116
|
+
if (plain && attention.act !== lastAct) safeWrite(characterAttentionLine(attention, state.petCharacter) + "\n");
|
|
117
|
+
if (pet.bell && !lastAct && attention.act && attention.items.some(item => item.level === "act" && item.kind !== "message") && Date.now() - lastBell >= 60000) { terminal.write("\x07"); lastBell = Date.now(); }
|
|
118
|
+
lastAct = attention.act;
|
|
119
|
+
}
|
|
120
|
+
if (plain) return;
|
|
121
|
+
const returningToStream = state.mode === "stream" && inAlternate;
|
|
101
122
|
if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
|
|
102
123
|
else if (state.mode === "stream" && inAlternate) {
|
|
103
|
-
terminal.write(`\x1b[?1049l\x1b[1;${rows -
|
|
124
|
+
terminal.write(`\x1b[?1049l\x1b[1;${rows - consoleFooterRows(state, columns, rows)}r`); inAlternate = false;
|
|
125
|
+
}
|
|
126
|
+
const footerStart = rows - consoleFooterRows(state, columns, rows) + 1;
|
|
127
|
+
if (state.mode === "stream" && priorStreamFooterStart !== undefined && priorStreamFooterStart < footerStart) {
|
|
128
|
+
for (let row = priorStreamFooterStart; row < footerStart; row++) terminal.write(`\x1b[${row};1H\x1b[2K`);
|
|
129
|
+
}
|
|
130
|
+
if (state.mode === "stream") priorStreamFooterStart = footerStart;
|
|
131
|
+
terminal.write(state.mode === "stream" ? `\x1b[1;${rows - consoleFooterRows(state, columns, rows)}r` : "\x1b[r");
|
|
132
|
+
if (returningToStream) {
|
|
104
133
|
if (droppedStream) writeStream({ text: `${plural(droppedStream, "older panel-mode event")} omitted from console memory; inspect hub.log for the full stream.` });
|
|
105
134
|
for (const event of pendingStream) writeStream(event);
|
|
106
135
|
pendingStream = []; droppedStream = 0;
|
|
107
136
|
}
|
|
108
|
-
|
|
137
|
+
if (state.mode === "stream" && state.petEnabled && petSize(state.petSize, columns, rows) !== "line" && activeNotice(state)) {
|
|
138
|
+
const key = JSON.stringify([state.noticeAt, state.notice]);
|
|
139
|
+
if (key !== lastExpandedNotice) { lastExpandedNotice = key; stream({ text: `Notice: ${state.notice}`, kind: "notice", tone: state.noticeTone ?? "failure" }, false); }
|
|
140
|
+
}
|
|
109
141
|
const lines = renderConsoleLines(state, columns, rows);
|
|
110
142
|
const start = rows - lines.length + 1;
|
|
111
143
|
for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
|
|
@@ -136,7 +168,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
136
168
|
if (!active) return;
|
|
137
169
|
const [status, budget, tasks, queue, ready] = replies;
|
|
138
170
|
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
139
|
-
if (status.status?.peers) state.peers = status.status.peers;
|
|
171
|
+
if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
|
|
172
|
+
if (status.status && status.ok !== false) {
|
|
173
|
+
state.attentionFacts = attentionFacts(status.status.attentionItems);
|
|
174
|
+
state.storageBlocked = !!status.status.deliveryError;
|
|
175
|
+
state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
|
|
176
|
+
}
|
|
140
177
|
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" });
|
|
141
178
|
if (budget.budget) state.budget = budget.budget;
|
|
142
179
|
if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
|
|
@@ -178,7 +215,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
178
215
|
if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
|
|
179
216
|
return;
|
|
180
217
|
}
|
|
181
|
-
if (action.type === "keys") return stream({ text: keyTable(columns).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
|
|
218
|
+
if (action.type === "keys") return stream({ text: keyTable(columns, !!state.petEnabled || !!state.petChoices?.length, !!state.petChoices?.length).map(line => paint(line, false)).join("\n"), kind: "console" }, false);
|
|
182
219
|
if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
|
|
183
220
|
if (action.type === "show") {
|
|
184
221
|
const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
|
|
@@ -201,6 +238,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
201
238
|
const handle = (key: string) => {
|
|
202
239
|
if (!active) return;
|
|
203
240
|
const result = reduceConsole(state, key); state = result.state;
|
|
241
|
+
state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
|
|
204
242
|
if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
|
|
205
243
|
for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
|
|
206
244
|
draw();
|
|
@@ -209,7 +247,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
209
247
|
client.onPush = msg => {
|
|
210
248
|
if (!active) return;
|
|
211
249
|
try {
|
|
212
|
-
if (msg.t === "event")
|
|
250
|
+
if (msg.t === "event") {
|
|
251
|
+
if (state.petEnabled && msg.e.t === "envelope" && !msg.e.dropped && msg.e.env.to?.includes("user")) state.attentionMessages = [...(state.attentionMessages ?? []), { id: msg.e.env.id, peer: msg.e.env.from, important: msg.e.env.priority === "important", since: msg.e.env.ts }].slice(-1000);
|
|
252
|
+
stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
|
|
253
|
+
}
|
|
213
254
|
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading, at => new Date(at).toLocaleTimeString())}`, peer: msg.peer, kind: "context" });
|
|
214
255
|
else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
|
|
215
256
|
else if (msg.t === "permission") {
|
|
@@ -230,6 +271,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
230
271
|
};
|
|
231
272
|
client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
|
|
232
273
|
try {
|
|
274
|
+
if (state.petEnabled && pet.notice) {
|
|
275
|
+
if (state.mode === "panels" && !plain) notify(state, pet.notice, Date.now(), "attention");
|
|
276
|
+
else stream({ text: pet.notice, kind: "attention", tone: "attention" }, false);
|
|
277
|
+
}
|
|
233
278
|
if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
|
|
234
279
|
if (terminal.isTTY) {
|
|
235
280
|
terminal.raw(true);
|
|
@@ -240,7 +285,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
240
285
|
if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
|
|
241
286
|
if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
|
|
242
287
|
else {
|
|
243
|
-
|
|
288
|
+
state.attentionMessages = []; state.attentionSelection = undefined;
|
|
289
|
+
if (state.help && state.mode === "panels") { draw(); return; }
|
|
244
290
|
if (state.confirm?.type === "permission_default") { draw(); return; }
|
|
245
291
|
state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
|
|
246
292
|
state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
|
|
4
4
|
// A failed or timed-out hub decides nothing, leaving Claude's native permission rules in effect.
|
|
5
5
|
import { relative, resolve, isAbsolute } from "node:path";
|
|
6
|
+
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
6
7
|
import { realPath } from "../hub/project.ts";
|
|
7
|
-
import { isAgentConfigPath, isPermissionMode, hardLinked, isHubWritePath } from "../hub/permission-mode.ts";
|
|
8
|
+
import { isAgentConfigPath, isInstructionPath, isPermissionMode, hardLinked, isHubWritePath } from "../hub/permission-mode.ts";
|
|
8
9
|
import { ControlClient } from "../hub/control-client.ts";
|
|
9
10
|
|
|
10
11
|
/** A library call may target another hub; only the managed command hook inherits that target's native identity. */
|
|
@@ -14,7 +15,7 @@ export function nativeHookIdentity(stateDir: string, peer: string, env: NodeJS.P
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** 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
|
+
export function projectFileTool(tool: unknown, input: unknown, projectRoot: unknown, protectInstructions = false, readOnly: string[] = []): boolean {
|
|
18
19
|
if (typeof projectRoot !== "string" || !isAbsolute(projectRoot) || !input || typeof input !== "object" || Array.isArray(input)) return false;
|
|
19
20
|
const args = input as Record<string, unknown>;
|
|
20
21
|
const key = ["Read", "Edit", "Write", "MultiEdit"].includes(String(tool)) ? "file_path" : tool === "NotebookEdit" ? "notebook_path" : ["LS", "Glob", "Grep"].includes(String(tool)) ? "path" : undefined;
|
|
@@ -24,7 +25,8 @@ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unkn
|
|
|
24
25
|
if (!optional && args[key] === undefined) return false;
|
|
25
26
|
try {
|
|
26
27
|
const root = realPath(projectRoot);
|
|
27
|
-
const
|
|
28
|
+
const mutates = ["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(String(tool));
|
|
29
|
+
const protectedPath = (path: string) => isAgentConfigPath(path) || isHubWritePath(path) || (protectInstructions && mutates && isInstructionPath(path));
|
|
28
30
|
const safe = (path: string) => {
|
|
29
31
|
const requested = resolve(root, path), lexical = relative(root, requested);
|
|
30
32
|
if (protectedPath(lexical)) return false;
|
|
@@ -34,6 +36,7 @@ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unkn
|
|
|
34
36
|
};
|
|
35
37
|
const target = typeof args[key] === "string" ? args[key] as string : root;
|
|
36
38
|
if (target.split(/[\\/]/).includes("..") || !safe(target)) return false;
|
|
39
|
+
if (mutates && matchesPolicy(root, target, readOnly, true)) return false;
|
|
37
40
|
if (tool === "Grep" && args.glob !== undefined) {
|
|
38
41
|
if (typeof args.glob !== "string" || !args.glob || args.glob.split(/[\\/]/).includes("..") || protectedPath(args.glob)) return false;
|
|
39
42
|
if (/[?*[{]/.test(args.glob) || !safe(resolve(root, target, args.glob))) return false;
|
|
@@ -48,6 +51,11 @@ export function projectFileTool(tool: unknown, input: unknown, projectRoot: unkn
|
|
|
48
51
|
} catch { return false; }
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/** Only a usable managed scoped-write grant may evaluate filesystem policy. */
|
|
55
|
+
export function scopedWriteBlocked(phase: string, permission: unknown, tool: unknown, evaluate: () => boolean): boolean {
|
|
56
|
+
return phase === "pre" && permission === "ask-when-needed" && ["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(String(tool)) && !evaluate();
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
/** The hook's stdout for one Claude Code hook input, or undefined for none. */
|
|
52
60
|
export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
|
|
53
61
|
const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
|
|
@@ -67,7 +75,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
67
75
|
}, timeoutMs);
|
|
68
76
|
if (phase !== "pre" || !res?.ok) return undefined;
|
|
69
77
|
const permission = isPermissionMode(res.permission) ? res.permission : "ask";
|
|
70
|
-
const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot);
|
|
78
|
+
const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot, res.protectInstructionFiles === true);
|
|
71
79
|
const allow = input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
|
|
72
80
|
const text = typeof res.text === "string" && res.text ? res.text : undefined;
|
|
73
81
|
if (!allow && !text) return undefined;
|
package/src/cli/help.ts
CHANGED
|
@@ -10,13 +10,17 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
10
10
|
["ahub projects [--json] [--color=auto|always|never]", "list registered projects and live status"],
|
|
11
11
|
["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
|
|
12
12
|
["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
|
|
13
|
-
["ahub init [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
|
|
13
|
+
["ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
|
|
14
14
|
["ahub help [command]", "this help, or one command's entries; also --help and -h"],
|
|
15
15
|
["ahub version | --version", "print the installed version"],
|
|
16
16
|
]],
|
|
17
17
|
["Daemon and runtime", [
|
|
18
18
|
["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
|
|
19
|
-
["ahub console [--panels] [--color=auto|always|never]", "enter the human console
|
|
19
|
+
["ahub console [--panels] [--color=auto|always|never]", "enter the human console; P cycles built-in pet characters/off; exiting leaves the daemon running"],
|
|
20
|
+
["ahub console --no-pet", "turn off attention for this session; combines with --panels and --color"],
|
|
21
|
+
["ahub pet [--short]", "read attention without connecting; --short prints ahub!N only when action is needed"],
|
|
22
|
+
["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
|
|
23
|
+
["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
|
|
20
24
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
21
25
|
["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
|
|
22
26
|
["ahub logs [-f]", "the last 100 lines of hub.log; -f shows the last 10 and follows it"],
|
|
@@ -35,30 +39,44 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
35
39
|
["Agents", [
|
|
36
40
|
["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
|
|
37
41
|
["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
|
|
38
|
-
["ahub kimi [--print-command] [--model <alias>]", "
|
|
42
|
+
["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
|
|
43
|
+
["ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional owned Jcode daemon/ACP; native policy unmanaged, no hub sandbox"],
|
|
44
|
+
["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"],
|
|
39
45
|
["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
|
|
40
46
|
["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
|
|
47
|
+
["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
|
|
48
|
+
["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
|
|
41
49
|
["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
|
|
42
50
|
["ahub models status [--json] [--color=auto|always|never]", "inspect local model state as labelled fields"],
|
|
43
51
|
["ahub models setup|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
|
|
44
52
|
]],
|
|
45
53
|
["Messages and approvals", [
|
|
46
54
|
["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"],
|
|
55
|
+
["ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]", "person-only transient activity from a headless ACP peer; q quits, approvals stay in console"],
|
|
47
56
|
["ahub tail", "live stream of messages, states and permission requests"],
|
|
48
57
|
["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"],
|
|
49
58
|
["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
|
|
59
|
+
["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
|
|
60
|
+
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
|
|
50
61
|
["ahub settings [list] [--json] | get <key>", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
|
|
51
|
-
["ahub settings set <key> <value|inherit> [--yes] [--preview] | undo", "human only: change one, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
|
|
62
|
+
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo", "human only: change one, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
|
|
52
63
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
53
64
|
["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
|
|
54
65
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
|
|
55
66
|
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
|
|
56
67
|
]],
|
|
57
68
|
["Tasks and review", [
|
|
58
|
-
["ahub board
|
|
59
|
-
["ahub
|
|
69
|
+
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
70
|
+
["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--color=auto|always|never]", "the task board; --ready: proposed tasks with nothing left to wait for"],
|
|
71
|
+
["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--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"],
|
|
72
|
+
["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits"],
|
|
73
|
+
["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
|
|
74
|
+
["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
|
|
75
|
+
["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
|
|
76
|
+
["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
|
|
60
77
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
61
|
-
["ahub task
|
|
78
|
+
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
79
|
+
["ahub task assign <id> <peer> [--role owner|reviewer]", "name the owner (default) or reviewer; a task that still waits reserves its owner"],
|
|
62
80
|
["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)"],
|
|
63
81
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
64
82
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
@@ -79,21 +97,29 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
79
97
|
["ahub report [--since 7d|<iso>] [--by task] [--json] [--color=auto|always|never]", "turns, tokens, messages, overlaps and task events per period"],
|
|
80
98
|
["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies"],
|
|
81
99
|
["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"],
|
|
100
|
+
["ahub research addons [--since 30d] [--all] [--json]", "approved tasks with each add-on (claude-mem, ponytail, ripwire, ...) on against off, with intervals; association, not cause"],
|
|
82
101
|
["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
|
|
83
102
|
["ahub research backfill", "build this project's research records once from events.jsonl"],
|
|
84
|
-
["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)"],
|
|
103
|
+
["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree); --addon and --memory declare the arm's add-on and memory state and refuse a setup that differs"],
|
|
85
104
|
["ahub bench list|status", "benchmark runs on this machine / the one running now"],
|
|
86
105
|
["ahub bench report <run> [--json]", "pass rate, first pass, rework, tokens and wall time per task and overall"],
|
|
87
106
|
["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"],
|
|
88
107
|
["ahub bench export [--format jsonl|csv]", "benchmark records for outside analysis: ids, outcomes and counts, never suite text"],
|
|
89
108
|
["ahub check-path <file> [--peer <id>]", "other owners' open tasks that claim or changed a file"],
|
|
109
|
+
["ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] [--json]", "the hub's own memory for this project: decisions, contracts, fails and findings, short- and long-term (docs/memory.md)"],
|
|
110
|
+
["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
|
|
111
|
+
["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
|
|
112
|
+
["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
|
|
113
|
+
["ahub memory import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
|
|
114
|
+
["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
|
|
115
|
+
["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks; take --force voids other machines' claims"],
|
|
90
116
|
]],
|
|
91
117
|
["Hooks", [
|
|
92
118
|
["ahub check-path --hook", "check-path as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks"],
|
|
93
119
|
["ahub facts --hook", "turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks"],
|
|
94
120
|
]],
|
|
95
121
|
["Dashboard", [
|
|
96
|
-
["ahub ui [--no-open]", "open the local dashboard (or print its one-time link)"],
|
|
122
|
+
["ahub ui [--no-open]", "open the local dashboard with configured attention character (or print its one-time link)"],
|
|
97
123
|
["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing and start settings"],
|
|
98
124
|
["ahub ui --all [--no-open]", "open the unified project dashboard"],
|
|
99
125
|
["ahub ui --all --stop", "stop only the dashboard manager"],
|