@staix/agent-hub 0.12.23 → 0.12.25
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 +109 -1
- 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 +10 -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 +8 -0
- package/docs/agent-notes/tests.md +5 -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 +666 -19
- 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 +2 -2
- 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 +288 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +633 -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/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -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 +171 -23
- 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 +914 -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 +115 -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 +905 -89
- package/src/hub/text-projection.ts +151 -0
- package/src/hub/tooling.ts +170 -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 +757 -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/pi/extension.ts +40 -5
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +843 -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/bench.ts
CHANGED
|
@@ -7,9 +7,11 @@ import { ControlClient } from "../hub/control-client.ts";
|
|
|
7
7
|
import { readEvents } from "../hub/events.ts";
|
|
8
8
|
import { hubGitArgv, hubGitEnv } from "../hub/git.ts";
|
|
9
9
|
import { taskRecords } from "../hub/research.ts";
|
|
10
|
-
import { appendBench, BENCH_SCHEMA, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
|
|
10
|
+
import { appendBench, BENCH_SCHEMA, memoryMode, type MemoryMode, parseSuite, suiteHash, type Attempt, type AttemptError, type AttemptMetrics, type Fingerprint, type Outcome, type SuiteTask } from "../hub/bench.ts";
|
|
11
11
|
import { hubHome, realPath } from "../hub/project.ts";
|
|
12
12
|
import { processSignature } from "../pi/process-signature.ts";
|
|
13
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
14
|
+
import { addonState, detectTooling, type Addon, type AddonName } from "../hub/tooling.ts";
|
|
13
15
|
import { VERSION } from "../version.ts";
|
|
14
16
|
|
|
15
17
|
/** What the runner needs from the world, so tests can drive it without a terminal. */
|
|
@@ -24,8 +26,12 @@ export interface BenchIo {
|
|
|
24
26
|
now(): number;
|
|
25
27
|
sleep(ms: number): Promise<void>;
|
|
26
28
|
log(line: string): void;
|
|
29
|
+
/** #248: the add-ons a peer works with; `detectTooling` on this machine unless a test supplies its own. */
|
|
30
|
+
tooling?(peer: string): Addon[] | Promise<Addon[]>;
|
|
31
|
+
/** #258: the memory settings the project's hub runs with; the project config unless a test supplies them. */
|
|
32
|
+
memoryConfig?(): { enabled: boolean; native?: boolean };
|
|
27
33
|
}
|
|
28
|
-
export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
|
|
34
|
+
export interface BenchOptions { cwd: string; stateDir: string; suitePath: string; arm: string; repeat: number; addons?: Partial<Record<AddonName, "on" | "off">>; memory?: MemoryMode; only?: string[]; home?: string; pollMs?: number; verifyTimeoutMs?: number; setupTimeoutMs?: number; settleMs?: number; stopped?: () => boolean }
|
|
29
35
|
|
|
30
36
|
const fail = (message: string): never => { throw new Error(message); };
|
|
31
37
|
|
|
@@ -192,12 +198,25 @@ export async function runBench(opts: BenchOptions, io: BenchIo): Promise<string>
|
|
|
192
198
|
// ponytail: the hub's status reports a requested model for Pi only and permission modes once #242 lands; the rest
|
|
193
199
|
// are recorded as absent. Reading each peer's launch settings would fill them in.
|
|
194
200
|
// The status lists every peer the hub has known; an arm is the ones attached now.
|
|
195
|
-
peers: Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
|
|
196
|
-
.map(([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {})
|
|
201
|
+
peers: (await Promise.all(Object.entries(status.peers ?? {}).filter(([, p]) => p.state !== "offline")
|
|
202
|
+
.map(async ([id, p]) => ({ id, ...(p.requestedModel ? { model: p.requestedModel } : {}), ...(p.permissionMode ? { permissionMode: p.permissionMode } : {}),
|
|
203
|
+
addons: await (io.tooling ?? ((peer: string) => detectTooling(peer, { cwd: opts.cwd })))(id) })))).sort((a, b) => a.id.localeCompare(b.id)),
|
|
197
204
|
routingHash: existsSync(routing) ? createHash("sha256").update(readFileSync(routing)).digest("hex") : null,
|
|
198
205
|
hubVersion: status.version,
|
|
199
206
|
};
|
|
207
|
+
// #248: a declared add-on condition must hold for the attached peers before anything is recorded or reset.
|
|
208
|
+
const addons = Object.entries(opts.addons ?? {}) as [AddonName, "on" | "off"][];
|
|
209
|
+
for (const [name, want] of addons) {
|
|
210
|
+
const found = addonState(name, fingerprint.peers.map((p) => p.addons));
|
|
211
|
+
if (found !== want) throw new Error(`the arm declares ${name} ${want}, but the attached peers have it ${found}; change their setup or the declaration (nothing was run)`);
|
|
212
|
+
}
|
|
213
|
+
// #258 AC11: the memory the project's hub runs with (its config, read at hub start) must be the arm's.
|
|
214
|
+
// ponytail: read from the project config, not the running hub; a config edited since the hub started reads wrong.
|
|
215
|
+
const runsWith = opts.memory ? memoryMode((io.memoryConfig ?? (() => loadConfig(opts.cwd).memory))()) : undefined;
|
|
216
|
+
if (opts.memory && runsWith !== opts.memory) throw new Error(`the arm declares memory ${opts.memory}, but this project's hub runs with ${runsWith}; change memory.enabled / memory.native and restart the hub, or the declaration (nothing was run)`);
|
|
217
|
+
const condition = { ...(addons.length ? { addons: Object.fromEntries(addons.sort(([a], [b]) => a.localeCompare(b))) } : {}), ...(opts.memory ? { memory: opts.memory } : {}) };
|
|
200
218
|
appendBench({ schema: BENCH_SCHEMA, kind: "run", run, suite: suite.name, suiteHash: suiteHash(suiteText), arm: opts.arm, fingerprint, version: VERSION,
|
|
219
|
+
...(Object.keys(condition).length ? { condition } : {}),
|
|
201
220
|
startedAt: new Date(io.now()).toISOString(), tasks: tasks.length, repeats: opts.repeat, order: tasks.map((t) => t.id), runner: { pid: process.pid, signature: processSignature(process.pid) ?? null } }, home);
|
|
202
221
|
io.log(`bench run ${run}: ${suite.name} [${opts.arm}], ${tasks.length} task${tasks.length === 1 ? "" : "s"} x ${opts.repeat}`);
|
|
203
222
|
let stopped: "timeout" | "interrupted" | "error" | undefined;
|
|
@@ -237,7 +256,7 @@ async function runAttempt(task: SuiteTask, commit: string, repeat: number, run:
|
|
|
237
256
|
// The attempt ends when its outcome is known: the wait for its measures is not part of its time.
|
|
238
257
|
const done = (outcome: Outcome, extra: { error?: AttemptError; verifyExit?: number | null; hubTask?: number | null; metrics?: AttemptMetrics | null; ended?: number; open?: boolean } = {}) => {
|
|
239
258
|
const ended = extra.ended ?? io.now();
|
|
240
|
-
const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
|
|
259
|
+
const attempt: Attempt = { schema: BENCH_SCHEMA, kind: "attempt", run, arm: opts.arm, task: task.id, repeat, outcome, ...(extra.error ? { error: extra.error } : {}), verifyExit: extra.verifyExit ?? null,
|
|
241
260
|
hubTask: extra.hubTask ?? null, startedAt: new Date(started).toISOString(), endedAt: new Date(ended).toISOString(), ms: ended - started, metrics: extra.metrics ?? null };
|
|
242
261
|
return { attempt, open: extra.open ?? false };
|
|
243
262
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Older same-protocol daemons may ignore newly added filter arguments. */
|
|
2
|
+
export function supportsBoardReleaseFilter(status: unknown): boolean {
|
|
3
|
+
if (!status || typeof status !== "object") return false;
|
|
4
|
+
const capability = (status as Record<string,unknown>).boardReadCapabilities;
|
|
5
|
+
return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).releaseFilter === true;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function supportsBoardColumnMetadata(status: unknown): boolean {
|
|
9
|
+
if (!status || typeof status !== "object") return false;
|
|
10
|
+
const capability = (status as Record<string,unknown>).boardReadCapabilities;
|
|
11
|
+
return !!capability && typeof capability === "object" && (capability as Record<string,unknown>).columnMetadata === true;
|
|
12
|
+
}
|
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;
|