@staix/agent-hub 0.12.22 → 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 +117 -0
- package/README.md +22 -9
- 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 +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- 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 +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- 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-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -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 +34 -16
- 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 +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- 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 +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- 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 +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- 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 +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- 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/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- 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 +132 -18
- 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 +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- 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 +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- 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/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- 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,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
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";
|
|
4
7
|
import { sanitize } from "../hub/envelope.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;
|
|
@@ -35,12 +43,7 @@ export function initialConsoleState(panels = false): ConsoleState {
|
|
|
35
43
|
return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
|
|
36
44
|
input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
|
|
37
45
|
}
|
|
38
|
-
|
|
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
|
-
}
|
|
46
|
+
export { permissionBoundary };
|
|
44
47
|
function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
|
|
45
48
|
if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
|
|
46
49
|
if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
|
|
@@ -82,9 +85,9 @@ export function terminalText(value: unknown): string {
|
|
|
82
85
|
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
|
|
83
86
|
export interface Span { text: string; tone?: Tone }
|
|
84
87
|
export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
|
|
85
|
-
info:
|
|
86
|
-
peerClaude:
|
|
87
|
-
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",
|
|
88
91
|
});
|
|
89
92
|
export function paint(line: Span[], color: boolean): string {
|
|
90
93
|
return line.map(span => {
|
|
@@ -174,7 +177,7 @@ export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TE
|
|
|
174
177
|
if (flag === "always") return true;
|
|
175
178
|
if (flag === "never") return false;
|
|
176
179
|
if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
|
|
177
|
-
return new Error("usage: ahub
|
|
180
|
+
return new Error("usage: ahub <command> [--color=auto|always|never]");
|
|
178
181
|
}
|
|
179
182
|
export function stateTone(state: string): Tone | undefined {
|
|
180
183
|
if (state === "idle" || state === "approved") return "success";
|
|
@@ -184,12 +187,15 @@ export function stateTone(state: string): Tone | undefined {
|
|
|
184
187
|
}
|
|
185
188
|
const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
|
|
186
189
|
/** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
|
|
187
|
-
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
|
+
};
|
|
188
194
|
/**
|
|
189
195
|
* Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
|
|
190
196
|
* prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
|
|
191
197
|
*/
|
|
192
|
-
function fitLine(line: Span[], columns: number): Span[] {
|
|
198
|
+
export function fitLine(line: Span[], columns: number): Span[] {
|
|
193
199
|
const clean = line.map(s => ({ ...s, text: flat(s.text) }));
|
|
194
200
|
if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
|
|
195
201
|
const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
|
|
@@ -216,7 +222,9 @@ export function fit(value: unknown, columns: number): string {
|
|
|
216
222
|
export function wrap(value: unknown, columns: number, hang = 4): string[] {
|
|
217
223
|
const lines: string[] = [];
|
|
218
224
|
const half = Math.floor(columns / 2);
|
|
219
|
-
|
|
225
|
+
// A bare chunk ending in "--- from" becomes a header marker when table padding follows it.
|
|
226
|
+
const quoteChunk = (text: string) => sanitize(text + " ").slice(0, -1);
|
|
227
|
+
const width = (text: string) => Bun.stringWidth(quoteChunk(text));
|
|
220
228
|
for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
|
|
221
229
|
const indent = /^\s*/.exec(part)![0];
|
|
222
230
|
const lead = Math.min(Bun.stringWidth(indent), half);
|
|
@@ -224,7 +232,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
|
|
|
224
232
|
const first = lines.length;
|
|
225
233
|
let start = " ".repeat(lead);
|
|
226
234
|
let line = start;
|
|
227
|
-
const push = () => { lines.push(
|
|
235
|
+
const push = () => { lines.push(quoteChunk(line.trimEnd())); line = start = pad; };
|
|
228
236
|
for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
|
|
229
237
|
if (width(line + token) <= columns) { line += token; continue; }
|
|
230
238
|
if (line !== start) push();
|
|
@@ -234,7 +242,7 @@ export function wrap(value: unknown, columns: number, hang = 4): string[] {
|
|
|
234
242
|
line += char;
|
|
235
243
|
}
|
|
236
244
|
}
|
|
237
|
-
if (line !== start || lines.length === first) lines.push(
|
|
245
|
+
if (line !== start || lines.length === first) lines.push(quoteChunk(line.trimEnd()));
|
|
238
246
|
}
|
|
239
247
|
return lines;
|
|
240
248
|
}
|
|
@@ -308,7 +316,7 @@ export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "opti
|
|
|
308
316
|
const KEYS: [string, string[]][] = [
|
|
309
317
|
["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
|
|
310
318
|
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
311
|
-
["Panels", ["1-5 panel", "j/k or
|
|
319
|
+
["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
|
|
312
320
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
313
321
|
["Peers", ["p pause", "r resume", "m permission mode"]],
|
|
314
322
|
["Tasks", ["a assign (then y)", "r review"]],
|
|
@@ -316,9 +324,9 @@ const KEYS: [string, string[]][] = [
|
|
|
316
324
|
["Events", ["f peer filter", "g kind filter"]],
|
|
317
325
|
];
|
|
318
326
|
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
319
|
-
export function keyTable(columns: number): Span[][] {
|
|
327
|
+
export function keyTable(columns: number, attention = false, characters = false): Span[][] {
|
|
320
328
|
const lines: Span[][] = []; const pad = 12;
|
|
321
|
-
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[]]] : [])]) {
|
|
322
330
|
let text = ""; let head = group;
|
|
323
331
|
const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
|
|
324
332
|
for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
|
|
@@ -344,7 +352,7 @@ function bound(s: ConsoleState): ConsoleState {
|
|
|
344
352
|
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
345
353
|
return s;
|
|
346
354
|
}
|
|
347
|
-
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"]);
|
|
348
356
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
349
357
|
export function parseConsoleCommand(input: string): string[] {
|
|
350
358
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -380,6 +388,7 @@ export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id
|
|
|
380
388
|
/** The selected row: the Approvals panel's is the selected request (-1 when none is). */
|
|
381
389
|
const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
|
|
382
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);
|
|
383
392
|
// A notice answers the key before this one; one that pruning raises now stays.
|
|
384
393
|
let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
|
|
385
394
|
const done = () => ({ state: bound(s), effects });
|
|
@@ -441,10 +450,54 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
441
450
|
} else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
|
|
442
451
|
return done();
|
|
443
452
|
}
|
|
453
|
+
if ((key === "n" || key === "P") && attentionKeyBlocked) return done();
|
|
444
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
|
+
}
|
|
445
494
|
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
446
495
|
if (key === ":") { s.editing = true; return done(); }
|
|
447
|
-
|
|
496
|
+
const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
497
|
+
if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
|
|
498
|
+
// Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
|
|
499
|
+
// digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
|
|
500
|
+
if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel((s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
|
|
448
501
|
if (key === "?") {
|
|
449
502
|
if (s.mode === "stream") effects.push({ type: "keys" });
|
|
450
503
|
else s.help = !s.help;
|
|
@@ -522,7 +575,10 @@ export function renderConsole(s: ConsoleState, columns: number, rows = 24, now =
|
|
|
522
575
|
return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
|
|
523
576
|
}
|
|
524
577
|
const NOTICE_MS = 10_000;
|
|
525
|
-
|
|
578
|
+
export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now = Date.now()): boolean {
|
|
579
|
+
return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
|
|
580
|
+
}
|
|
581
|
+
export const TABLES: Record<number, string[]> = {
|
|
526
582
|
1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
527
583
|
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
528
584
|
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
@@ -531,16 +587,16 @@ const TABLES: Record<number, string[]> = {
|
|
|
531
587
|
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
532
588
|
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
533
589
|
/** One span per column of a panel row; zero counters and unknown values read `-`. */
|
|
534
|
-
function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
|
|
590
|
+
export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
|
|
535
591
|
const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
|
|
536
592
|
if (s.panel === 1) {
|
|
537
593
|
const b = s.budget[row.id];
|
|
538
594
|
const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
|
|
539
595
|
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
540
596
|
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
541
|
-
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)),
|
|
542
598
|
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
543
|
-
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),
|
|
544
600
|
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
545
601
|
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
546
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(", ") : "-"),
|
|
@@ -554,23 +610,26 @@ function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages
|
|
|
554
610
|
const stage = stages?.get(row.id);
|
|
555
611
|
const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
556
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)),
|
|
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))];
|
|
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))];
|
|
558
614
|
}
|
|
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")];
|
|
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")];
|
|
560
616
|
const [header, ...body] = String(row.text).split("\n");
|
|
561
|
-
|
|
617
|
+
// The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
|
|
618
|
+
// text the stream tokenizes, so an event reads the same in both places (#309); body lines stay plain, as in the
|
|
619
|
+
// stream. Colour off concatenates to the same bytes.
|
|
620
|
+
return [...streamTokens((header ?? "").replace(/\t/g, " "), row.tone, row.kind), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
|
|
562
621
|
}
|
|
563
622
|
/**
|
|
564
623
|
* A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
|
|
565
624
|
* widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
|
|
566
|
-
* takes the rest.
|
|
625
|
+
* takes the rest. One-shot callers may supply widths for already-wrapped cells; console defaults stay unchanged.
|
|
567
626
|
*/
|
|
568
|
-
function table(head: string[], rows: Span[][], columns: number): Span[][] {
|
|
627
|
+
export function table(head: string[], rows: Span[][], columns: number, explicitWidths?: readonly number[]): Span[][] {
|
|
569
628
|
const cap = Math.max(24, Math.floor(columns / 3));
|
|
570
629
|
const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
|
|
571
|
-
const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
|
|
630
|
+
const widths = explicitWidths ? [...explicitWidths] : head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
|
|
572
631
|
const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
|
|
573
|
-
if (head.includes("STAGE")) {
|
|
632
|
+
if (!explicitWidths && head.includes("STAGE")) {
|
|
574
633
|
// Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
|
|
575
634
|
const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
|
|
576
635
|
for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
|
|
@@ -579,7 +638,9 @@ function table(head: string[], rows: Span[][], columns: number): Span[][] {
|
|
|
579
638
|
}
|
|
580
639
|
widths[flexible] = Math.max(0, room());
|
|
581
640
|
}
|
|
582
|
-
|
|
641
|
+
const headerCells = explicitWidths ? head.map((h, i) => wrap(h, Math.max(1, widths[i]!), 0)) : undefined;
|
|
642
|
+
const headers = headerCells ? Array.from({ length: Math.max(...headerCells.map(cell => cell.length)) }, (_, n) => head.map((_, i) => span(headerCells[i]![n] ?? "", "info"))) : [head.map(h => span(h, "info"))];
|
|
643
|
+
return [...headers, ...rows].map(row => row.map((cell, i) => {
|
|
583
644
|
if (i === head.length - 1 && flexible === i) return cell;
|
|
584
645
|
const text = fit(cell.text, widths[i]!);
|
|
585
646
|
return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
|
|
@@ -611,6 +672,35 @@ function header(s: ConsoleState, columns: number): Span[] {
|
|
|
611
672
|
}
|
|
612
673
|
/** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
|
|
613
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
|
+
}
|
|
614
704
|
export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
|
|
615
705
|
const permission = selectedApproval(s);
|
|
616
706
|
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
@@ -626,14 +716,14 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
626
716
|
if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
|
|
627
717
|
if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
|
|
628
718
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
629
|
-
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"));
|
|
630
720
|
}
|
|
631
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(" ");
|
|
632
722
|
if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
|
|
633
723
|
const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
|
|
634
724
|
if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
|
|
635
725
|
else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
|
|
636
|
-
const noticeShown =
|
|
726
|
+
const noticeShown = activeNotice(s, now);
|
|
637
727
|
if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
|
|
638
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;
|
|
639
729
|
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
@@ -649,13 +739,15 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
649
739
|
if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
|
|
650
740
|
footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
|
|
651
741
|
}
|
|
652
|
-
return
|
|
742
|
+
return decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown);
|
|
653
743
|
}
|
|
654
744
|
const progress = taskProgress(s.tasks);
|
|
655
|
-
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;
|
|
656
748
|
const lines: Span[][] = [];
|
|
657
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());
|
|
658
|
-
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));
|
|
659
751
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
660
752
|
const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
661
753
|
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
@@ -696,5 +788,5 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
696
788
|
}
|
|
697
789
|
}
|
|
698
790
|
const body = lines.slice(0, height); while (body.length < height) body.push([]);
|
|
699
|
-
return [header(s, columns), rule, ...body, rule,
|
|
791
|
+
return [header(s, columns), rule, ...noticeLines, ...body, ...decoratePetFooter(s, columns, rows, now, rule, footer, noticeShown)];
|
|
700
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,8 +27,9 @@ 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
|
-
runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
|
|
32
|
+
runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
|
|
27
33
|
pollMs?: number;
|
|
28
34
|
}
|
|
29
35
|
export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
|
|
@@ -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,15 +85,22 @@ 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
|
-
const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) =>
|
|
92
|
+
const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
|
|
93
|
+
// Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
|
|
94
|
+
if (event.kind === "command" && Bun.stringWidth(text) <= columns) return [[{ text }]];
|
|
95
|
+
return index === 0 ? wrapStreamTokens(text, columns, event.tone, event.kind) : wrap(text, columns).map(line => [{ text: line }]);
|
|
96
|
+
});
|
|
80
97
|
const writeStream = (event: ConsoleEvent) => {
|
|
81
|
-
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
|
|
82
99
|
for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
|
|
83
100
|
};
|
|
84
101
|
/** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
|
|
85
102
|
const stream = (event: ConsoleEvent, record = true) => {
|
|
103
|
+
if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
|
|
86
104
|
if (record) state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
|
|
87
105
|
if (plain) {
|
|
88
106
|
const [header, ...body] = terminalText(event.text).split("\n");
|
|
@@ -92,15 +110,34 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
92
110
|
else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
|
|
93
111
|
};
|
|
94
112
|
const draw = () => {
|
|
95
|
-
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;
|
|
96
122
|
if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
|
|
97
123
|
else if (state.mode === "stream" && inAlternate) {
|
|
98
|
-
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) {
|
|
99
133
|
if (droppedStream) writeStream({ text: `${plural(droppedStream, "older panel-mode event")} omitted from console memory; inspect hub.log for the full stream.` });
|
|
100
134
|
for (const event of pendingStream) writeStream(event);
|
|
101
135
|
pendingStream = []; droppedStream = 0;
|
|
102
136
|
}
|
|
103
|
-
|
|
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
|
+
}
|
|
104
141
|
const lines = renderConsoleLines(state, columns, rows);
|
|
105
142
|
const start = rows - lines.length + 1;
|
|
106
143
|
for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
|
|
@@ -131,7 +168,12 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
131
168
|
if (!active) return;
|
|
132
169
|
const [status, budget, tasks, queue, ready] = replies;
|
|
133
170
|
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
134
|
-
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
|
+
}
|
|
135
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" });
|
|
136
178
|
if (budget.budget) state.budget = budget.budget;
|
|
137
179
|
if (tasks?.ok) { const parsed = JSON.parse(tasks.text); if (Array.isArray(parsed)) {
|
|
@@ -145,9 +187,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
145
187
|
} catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
|
|
146
188
|
finally { polling = false; }
|
|
147
189
|
};
|
|
148
|
-
const run = options.runCommand ?? ((args, output, finished) => {
|
|
190
|
+
const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
|
|
149
191
|
const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
|
|
150
192
|
cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
193
|
+
env: { ...process.env, COLUMNS: String(commandColumns), NO_COLOR: "1" },
|
|
151
194
|
});
|
|
152
195
|
const read = async (source: ReadableStream<Uint8Array>) => {
|
|
153
196
|
const decoder = new TextDecoder(); const reader = source.getReader();
|
|
@@ -172,7 +215,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
172
215
|
if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
|
|
173
216
|
return;
|
|
174
217
|
}
|
|
175
|
-
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);
|
|
176
219
|
if (action.type === "print") return stream({ text: action.text, kind: action.kind, ...(action.tone ? { tone: action.tone } : {}) }, false);
|
|
177
220
|
if (action.type === "show") {
|
|
178
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);
|
|
@@ -188,13 +231,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
188
231
|
stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
|
|
189
232
|
try {
|
|
190
233
|
// Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
|
|
191
|
-
const output = (text: string) => { if (active) { stream({ text: text.replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
|
|
192
|
-
cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); });
|
|
234
|
+
const output = (text: string) => { if (active) { stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
|
|
235
|
+
cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); }, Math.max(1, columns - 4));
|
|
193
236
|
} catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
|
|
194
237
|
};
|
|
195
238
|
const handle = (key: string) => {
|
|
196
239
|
if (!active) return;
|
|
197
240
|
const result = reduceConsole(state, key); state = result.state;
|
|
241
|
+
state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
|
|
198
242
|
if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
|
|
199
243
|
for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
|
|
200
244
|
draw();
|
|
@@ -203,7 +247,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
203
247
|
client.onPush = msg => {
|
|
204
248
|
if (!active) return;
|
|
205
249
|
try {
|
|
206
|
-
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
|
+
}
|
|
207
254
|
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading, at => new Date(at).toLocaleTimeString())}`, peer: msg.peer, kind: "context" });
|
|
208
255
|
else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
|
|
209
256
|
else if (msg.t === "permission") {
|
|
@@ -224,17 +271,26 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
224
271
|
};
|
|
225
272
|
client.onClose = (_code, reason) => stop(`Hub connection closed${reason ? `: ${reason}` : ""}`);
|
|
226
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
|
+
}
|
|
227
278
|
if (plain) { state.mode = "stream"; safeWrite("Console panels need a terminal of at least 80x24; showing the plain stream.\n"); }
|
|
228
279
|
if (terminal.isTTY) {
|
|
229
280
|
terminal.raw(true);
|
|
230
281
|
removers.push(terminal.onData(text => {
|
|
231
282
|
// A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
|
|
232
|
-
// Under the modal key table it does nothing at all.
|
|
283
|
+
// Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is that many
|
|
284
|
+
// key presses (key repeat, a slow link), not a paste (#309): each arrow reaches the reducer on its own.
|
|
233
285
|
if (Array.from(text).length > 1 && !["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"].includes(text)) {
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
if (/^(?:\x1b\[[ABCD])+$/.test(text)) { for (const key of text.match(/\x1b\[[ABCD]/g)!) handle(key); }
|
|
287
|
+
else {
|
|
288
|
+
state.attentionMessages = []; state.attentionSelection = undefined;
|
|
289
|
+
if (state.help && state.mode === "panels") { draw(); return; }
|
|
290
|
+
if (state.confirm?.type === "permission_default") { draw(); return; }
|
|
291
|
+
state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
|
|
292
|
+
state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
|
|
293
|
+
}
|
|
238
294
|
} else handle(text);
|
|
239
295
|
}));
|
|
240
296
|
removers.push(terminal.onResize(() => {
|
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;
|