@staix/agent-hub 0.12.30 → 0.12.32
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 +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -74
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/cli/attention.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface AttentionState {
|
|
|
9
9
|
storageBlocked?: boolean;
|
|
10
10
|
recoveryNeed?: { id?: string; running: boolean; since?: number };
|
|
11
11
|
queue?: readonly { id: string; peer?: string; state: string }[];
|
|
12
|
-
attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
|
|
12
|
+
attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
|
|
13
13
|
}
|
|
14
14
|
/** Allowlisted adapter between console-owned facts and the shared body-free model. */
|
|
15
15
|
export function consoleNeeds(state: AttentionState, now: number): Needs {
|
|
@@ -42,6 +42,8 @@ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attent
|
|
|
42
42
|
if (!Array.isArray(raw)) return [];
|
|
43
43
|
return raw.flatMap<NonNullable<AttentionState["attentionFacts"]>[number]>(row => {
|
|
44
44
|
if (!row || typeof row !== "object" || !Number.isSafeInteger(row.id) || row.id < 1) return [];
|
|
45
|
+
if (row.kind === "stall" && row.level === "act") return [{ kind: "stall" as const, id: row.id }];
|
|
46
|
+
if (row.kind === "offer" && row.level === "act" && typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer)) return [{ kind: "offer" as const, id: row.id, peer: row.peer }];
|
|
45
47
|
if (row.kind === "review" && row.level === "act") return [{ kind: "review" as const, id: row.id }];
|
|
46
48
|
if (row.kind === "offline" && row.level === "watch" && typeof row.peer === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(row.peer)) return [{ kind: "offline" as const, id: row.id, peer: row.peer }];
|
|
47
49
|
return [];
|
|
@@ -49,6 +51,8 @@ export function attentionFacts(raw: unknown): NonNullable<AttentionState["attent
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export function attentionCommand(item: NeedItem): string {
|
|
54
|
+
if (item.kind === "offer") return `open ${item.peer}: task #${item.id} is waiting for it; ahub task show ${item.id}`;
|
|
55
|
+
if (item.kind === "stall") return `ahub task show ${item.id}; configure the peer or ahub task assign ${item.id} <peer>, or ahub task cancel ${item.id} --reason <text>`;
|
|
52
56
|
if (item.kind === "delivery") return `ahub queue list${item.id ? `; ahub queue show ${item.id}` : ""}`;
|
|
53
57
|
if (item.kind === "review") return `ahub task show ${item.id}; ahub review ${item.id} approved|changes_requested`;
|
|
54
58
|
if (item.kind === "offline") return `ahub status; ahub task show ${item.id}; ahub task assign ${item.id} <peer>`;
|
package/src/cli/console-state.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attent
|
|
|
2
2
|
import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
|
|
3
3
|
import { PEER_PALETTE } from "../ui/peer-palette.ts";
|
|
4
4
|
import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
|
|
5
|
+
import { PLANNER_PEERS } from "../hub/peer-catalog.ts";
|
|
5
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
6
7
|
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
7
8
|
import { sanitize } from "../hub/envelope.ts";
|
|
9
|
+
import type { SettingRow, SettingValue } from "../hub/settings.ts";
|
|
8
10
|
|
|
9
11
|
export interface Approval {
|
|
10
12
|
id: string; peer: string; title: string; expiresAt: number;
|
|
@@ -12,10 +14,18 @@ export interface Approval {
|
|
|
12
14
|
}
|
|
13
15
|
export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
|
|
14
16
|
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
17
|
+
export interface SettingsEdit {
|
|
18
|
+
token: number; key?: string; undo: boolean; phase: "value" | "preview" | "reason" | "confirm";
|
|
19
|
+
text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
|
|
20
|
+
}
|
|
15
21
|
export interface ConsoleState {
|
|
22
|
+
settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
|
|
23
|
+
settingsEdit?: SettingsEdit; settingsSequence?: number;
|
|
24
|
+
pendingRestart?: { key: string; reason: string }[];
|
|
25
|
+
features?: readonly string[];
|
|
16
26
|
/** Older embedding callers omit choices and retain the original STAI/off toggle. */
|
|
17
27
|
petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
|
|
18
|
-
attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
|
|
28
|
+
attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
|
|
19
29
|
attentionFingerprint?: string;
|
|
20
30
|
petEnabled?: boolean; petBlink?: boolean; attentionMessages?: { id?: string; peer?: string; important: boolean; since?: number }[]; storageBlocked?: boolean; recoveryNeed?: { id?: string; running: boolean; since?: number }; attentionSelection?: number;
|
|
21
31
|
mode: "stream" | "panels"; panel: number; selection: number;
|
|
@@ -40,6 +50,8 @@ export interface ConsoleState {
|
|
|
40
50
|
/** A detail view: text as the stream shows it, or labeled fields. */
|
|
41
51
|
export type Detail = string | Record<string, unknown>;
|
|
42
52
|
export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
|
|
53
|
+
{ type: "settings_preview"; token: number; key?: string; value?: SettingValue; undo: boolean } |
|
|
54
|
+
{ type: "settings_write"; key?: string; value?: SettingValue; undo: boolean; reason: string; confirm?: string; undoDigest?: string } |
|
|
43
55
|
{ type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
|
|
44
56
|
export function initialConsoleState(panels = false): ConsoleState {
|
|
45
57
|
return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
|
|
@@ -67,7 +79,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
|
|
|
67
79
|
s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
|
|
68
80
|
const pending = s.confirm;
|
|
69
81
|
if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
|
|
70
|
-
if (s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
|
|
82
|
+
if (s.settingsEdit || s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
|
|
71
83
|
const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
|
|
72
84
|
if (!next) return false;
|
|
73
85
|
s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
|
|
@@ -315,15 +327,19 @@ function approvalFields(a: Approval): [string, unknown][] {
|
|
|
315
327
|
export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
|
|
316
328
|
return ` ? ${a.peer} asks permission: ${String(a.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${a.id} <${a.options.map(o => `${quoted(o.optionId)} (${quoted(o.name)})`).join(", ")}> | deny`;
|
|
317
329
|
}
|
|
330
|
+
/** #441: Home/End as terminals send them, plus `<` and `>` for keyboards without those keys. */
|
|
331
|
+
export const FIRST_KEYS = ["<", "\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"];
|
|
332
|
+
export const LAST_KEYS = [">", "\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"];
|
|
318
333
|
const KEYS: [string, string[]][] = [
|
|
319
334
|
["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
|
|
320
335
|
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
321
|
-
["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
|
|
336
|
+
["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Home/< first, End/> last", "Enter view", "j/k scroll a view", "w web UI", "o pet window"]],
|
|
322
337
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
323
|
-
["Peers", ["p pause", "r resume", "m permission mode"]],
|
|
338
|
+
["Peers", ["p pause", "r resume", "m permission mode", "l make planner (then y)"]],
|
|
324
339
|
["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
|
|
325
340
|
["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
|
|
326
341
|
["Events", ["f peer filter", "g kind filter"]],
|
|
342
|
+
["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
|
|
327
343
|
];
|
|
328
344
|
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
329
345
|
export function keyTable(columns: number, attention = false, characters = false): Span[][] {
|
|
@@ -354,7 +370,7 @@ function bound(s: ConsoleState): ConsoleState {
|
|
|
354
370
|
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
355
371
|
return s;
|
|
356
372
|
}
|
|
357
|
-
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report"]);
|
|
373
|
+
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report", "ui", "pet"]);
|
|
358
374
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
359
375
|
export function parseConsoleCommand(input: string): string[] {
|
|
360
376
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -374,7 +390,7 @@ export function parseConsoleCommand(input: string): string[] {
|
|
|
374
390
|
}
|
|
375
391
|
export function commandNeedsConfirmation(args: string[]): boolean {
|
|
376
392
|
const positional = args.filter(arg => arg !== "--yes");
|
|
377
|
-
return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
|
|
393
|
+
return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign", "cancel"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
|
|
378
394
|
}
|
|
379
395
|
export function panelRows(s: ConsoleState): any[] {
|
|
380
396
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
@@ -383,9 +399,19 @@ export function panelRows(s: ConsoleState): any[] {
|
|
|
383
399
|
const states = ["proposed", "in_progress", "in_review", "changes_requested"];
|
|
384
400
|
return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
|
|
385
401
|
}
|
|
402
|
+
if (s.panel === 6) return [...(s.settingsRows ?? [])].sort((a, b) => a.group.localeCompare(b.group) || a.key.localeCompare(b.key));
|
|
386
403
|
if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
|
|
387
404
|
return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
|
|
388
405
|
}
|
|
406
|
+
/** #441: Events follow the newest row while the selection is on the last one; a row the person moved to stays put. */
|
|
407
|
+
export function appendEvent(s: ConsoleState, event: ConsoleEvent, cap = 1000): void {
|
|
408
|
+
if (s.panel !== 5) { s.events = [...s.events, event].slice(-cap); return; }
|
|
409
|
+
const before = panelRows(s), held = before[s.selection], following = !held || s.selection >= before.length - 1;
|
|
410
|
+
s.events = [...s.events, event].slice(-cap);
|
|
411
|
+
const rows = panelRows(s);
|
|
412
|
+
s.selection = following ? Math.max(0, rows.length - 1) : Math.max(0, rows.indexOf(held));
|
|
413
|
+
}
|
|
414
|
+
const newestEvent = (s: ConsoleState) => Math.max(0, panelRows(s).length - 1);
|
|
389
415
|
export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
|
|
390
416
|
/** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
|
|
391
417
|
const selection = (s: ConsoleState) => {
|
|
@@ -412,6 +438,7 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
|
|
|
412
438
|
const action = commandFlag(args, "--action") ?? "resolve";
|
|
413
439
|
return `${action} delivery ${lock.id} at revision ${lock.revision}${action === "completed" ? "; this asserts external effects" : action === "discard" ? "; no task or native turn is cancelled" : ""}`;
|
|
414
440
|
}
|
|
441
|
+
if (lock.prefix[1] === "cancel") return `cancel task #${lock.id} at revision ${lock.revision}; native turns and worktrees are retained`;
|
|
415
442
|
const role = commandFlag(args, "--role") ?? "owner";
|
|
416
443
|
if (lock.prefix[1] === "assign") return `assign ${role} on task #${lock.id} at revision ${lock.revision}${lock.waiting && role === "owner" ? " as a reservation until dependencies approve" : ""}`;
|
|
417
444
|
const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
|
|
@@ -420,12 +447,41 @@ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args
|
|
|
420
447
|
return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
|
|
421
448
|
}
|
|
422
449
|
export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
|
|
423
|
-
const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
|
|
450
|
+
const attentionKeyBlocked = !!(state.settingsEdit || state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
|
|
424
451
|
// A notice answers the key before this one; one that pruning raises now stays.
|
|
425
452
|
let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
|
|
426
453
|
const done = () => ({ state: bound(s), effects });
|
|
427
454
|
const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
|
|
428
455
|
if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
|
|
456
|
+
if (s.settingsEdit) {
|
|
457
|
+
const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
|
|
458
|
+
if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
|
|
459
|
+
if (edit.phase === "preview") return done();
|
|
460
|
+
if (edit.phase === "confirm") {
|
|
461
|
+
s.settingsEdit = undefined;
|
|
462
|
+
if (key === "y") {
|
|
463
|
+
if (edit.undo && (!edit.key || !edit.undoDigest)) { notify(s, "undo preview changed; reload settings and review it again", now); return done(); }
|
|
464
|
+
effects.push({ type: "settings_write", undo: edit.undo, ...(edit.key ? { key: edit.key } : {}), ...(!edit.undo ? { value: edit.value } : { undoDigest: edit.undoDigest }), reason: edit.reason!, ...(edit.confirm ? { confirm: edit.confirm } : {}) });
|
|
465
|
+
}
|
|
466
|
+
return done();
|
|
467
|
+
}
|
|
468
|
+
if (key === "\x7f" || key === "\b") edit.text = Array.from(edit.text).slice(0, -1).join("");
|
|
469
|
+
else if (key === "\x15") edit.text = "";
|
|
470
|
+
else if (key === "\r" || key === "\n") {
|
|
471
|
+
if (edit.phase === "reason") {
|
|
472
|
+
if (!edit.text.trim() || Buffer.byteLength(edit.text.trim()) > 400) { notify(s, "give a reason from 1 to 400 UTF-8 bytes", now); return done(); }
|
|
473
|
+
edit.reason = edit.text.trim(); edit.text = ""; edit.phase = "confirm";
|
|
474
|
+
} else {
|
|
475
|
+
const row = s.settingsRows?.find(row => row.key === edit.key);
|
|
476
|
+
if (!row) { s.settingsEdit = undefined; notify(s, "setting is no longer available", now); return done(); }
|
|
477
|
+
const text = edit.text;
|
|
478
|
+
edit.value = text === "inherit" ? null : row.type === "boolean" ? text === "true" ? true : text === "false" ? false : text : row.type === "number" ? text.trim() && Number.isFinite(Number(text)) ? Number(text) : text : row.type === "integer" ? /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text : row.type === "peers" || row.type === "roles" ? text.split(",").map(value => value.trim()).filter(Boolean) : text;
|
|
479
|
+
edit.phase = "preview"; edit.text = "";
|
|
480
|
+
effects.push({ type: "settings_preview", token: edit.token, key: edit.key, value: edit.value, undo: false });
|
|
481
|
+
}
|
|
482
|
+
} else if (!key.startsWith("\x1b")) edit.text += terminalText(key).replace(/\n/g, "").slice(0, Math.max(0, 1000 - edit.text.length));
|
|
483
|
+
return done();
|
|
484
|
+
}
|
|
429
485
|
if (s.confirm) {
|
|
430
486
|
const confirm = s.confirm; s.confirm = undefined;
|
|
431
487
|
if (confirm.type === "permission_default") {
|
|
@@ -530,17 +586,39 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
530
586
|
}
|
|
531
587
|
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
532
588
|
if (key === ":") { s.editing = true; return done(); }
|
|
533
|
-
const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
534
|
-
if (s.mode === "panels" && /^[1-
|
|
589
|
+
const switchPanel = (panel: number) => { s.panel = panel; s.selection = panel === 5 ? newestEvent(s) : 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
590
|
+
if (s.mode === "panels" && /^[1-6]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
|
|
535
591
|
// Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
|
|
536
592
|
// digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
|
|
537
|
-
if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel((s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
|
|
593
|
+
if (s.mode === "panels" && (key === "\x1b[C" || key === "\x1b[D") && !s.optionChoice) return switchPanel(s.panel === 6 ? key === "\x1b[C" ? 1 : 5 : (s.panel + (key === "\x1b[C" ? 0 : 3)) % 5 + 1);
|
|
594
|
+
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
|
|
595
|
+
const row = selectedPanelRow(s);
|
|
596
|
+
if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
|
|
597
|
+
if (key === "e" && !row) return done();
|
|
598
|
+
const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
|
|
599
|
+
s.settingsEdit = { token, undo: key === "u", ...(key === "e" ? { key: row.key } : {}), phase: key === "u" ? "preview" : "value", text: key === "e" ? settingValueText(row.fileValue) : "" };
|
|
600
|
+
if (key === "u") effects.push({ type: "settings_preview", token, undo: true });
|
|
601
|
+
return done();
|
|
602
|
+
}
|
|
538
603
|
if (key === "?") {
|
|
539
604
|
if (s.mode === "stream") effects.push({ type: "keys" });
|
|
540
605
|
else s.help = !s.help;
|
|
541
606
|
return done();
|
|
542
607
|
}
|
|
543
608
|
if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
|
|
609
|
+
// #452: open the project dashboard or the pet window as a person's console command; both return at once.
|
|
610
|
+
if (s.mode === "panels" && (key === "w" || key === "o") && !s.optionChoice) {
|
|
611
|
+
effects.push({ type: "command", args: key === "w" ? ["ui"] : ["pet", "--window"] });
|
|
612
|
+
return done();
|
|
613
|
+
}
|
|
614
|
+
if (s.mode === "panels" && (FIRST_KEYS.includes(key) || LAST_KEYS.includes(key))) {
|
|
615
|
+
const first = FIRST_KEYS.includes(key);
|
|
616
|
+
if (s.detail !== undefined || s.requestDetail) { if (first) s.detailOffset = 0; return done(); }
|
|
617
|
+
s.selection = first ? 0 : Math.max(0, panelRows(s).length - 1);
|
|
618
|
+
if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
|
|
619
|
+
captureConsoleSelection(s, true);
|
|
620
|
+
return done();
|
|
621
|
+
}
|
|
544
622
|
if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
|
|
545
623
|
if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
|
|
546
624
|
s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
|
|
@@ -595,9 +673,25 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
595
673
|
else s.modeChoice = peer;
|
|
596
674
|
return done();
|
|
597
675
|
}
|
|
676
|
+
// #455: the planner is a setting; the command runs only after y, as a person's terminal command.
|
|
677
|
+
if (s.panel === 1 && key === "l") {
|
|
678
|
+
const peer = String(item);
|
|
679
|
+
if (!(PLANNER_PEERS as readonly string[]).includes(peer)) notify(s, `${peer} cannot plan: the planner is one of ${PLANNER_PEERS.join(", ")}`, now);
|
|
680
|
+
else if (s.peers[peer]?.planner === "setting") notify(s, `${peer} is already the pinned planner`, now, "attention");
|
|
681
|
+
else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
|
|
682
|
+
return done();
|
|
683
|
+
}
|
|
598
684
|
const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
|
|
599
685
|
s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
|
|
600
686
|
if (prefill) { s.input = prefill; s.editing = true; return done(); }
|
|
687
|
+
if (s.panel === 3 && key === "c") {
|
|
688
|
+
if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
|
|
689
|
+
const revision = selected.flowRevision;
|
|
690
|
+
if (!["proposed", "in_progress", "in_review", "changes_requested", "backlog"].includes(selected.state)) return done();
|
|
691
|
+
if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
|
|
692
|
+
s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state) };
|
|
693
|
+
s.input = `task cancel ${item} --revision ${revision} --reason `; s.editing = true; return done();
|
|
694
|
+
}
|
|
601
695
|
if (s.panel === 3 && (key === "a" || key === "u")) {
|
|
602
696
|
if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
|
|
603
697
|
const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
|
|
@@ -624,7 +718,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
624
718
|
const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
|
|
625
719
|
const next = values[values.indexOf(current ?? "") + 1];
|
|
626
720
|
if (key === "f") s.peerFilter = next; else s.kindFilter = next;
|
|
627
|
-
s.selection =
|
|
721
|
+
s.selection = newestEvent(s); return done();
|
|
628
722
|
}
|
|
629
723
|
if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
|
|
630
724
|
return done();
|
|
@@ -641,6 +735,7 @@ export const TABLES: Record<number, string[]> = {
|
|
|
641
735
|
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
642
736
|
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
643
737
|
4: ["ID", "PEER", "STATE", "REV", "AGE"],
|
|
738
|
+
6: ["GROUP", "KEY", "VALUE", "SOURCE", "APPLIES"],
|
|
644
739
|
};
|
|
645
740
|
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
646
741
|
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
@@ -652,7 +747,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
652
747
|
const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
|
|
653
748
|
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
654
749
|
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
655
|
-
return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
|
|
750
|
+
return [id(row.planner ? `${row.id} (planner)` : row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
|
|
656
751
|
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
657
752
|
span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
658
753
|
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
@@ -666,10 +761,11 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
666
761
|
const at = last?.at ?? row.updated ?? row.created;
|
|
667
762
|
const failed = last?.event === "check failed" || last?.event === "failed";
|
|
668
763
|
const stage = stages?.get(row.id);
|
|
669
|
-
const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
670
|
-
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)),
|
|
671
|
-
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))];
|
|
764
|
+
const meter = stage?.cancelled ? "[----] cancelled" : stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
765
|
+
return [id(`#${row.id}`, "info"), span(`${row.state}${s.attentionFacts?.some(fact => fact.kind === "stall" && fact.id === row.id) ? " stalled" : ""}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
|
|
766
|
+
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?.cancelled ? "muted" : stage?.changes ? "failure" : stateTone(row.state))];
|
|
672
767
|
}
|
|
768
|
+
if (s.panel === 6) return [span(row.group), span(row.key, selected ? "strong" : "info"), span(settingValueText(row.value)), span(row.source), span(`${row.applies}${row.pending !== undefined ? ` (file ${settingValueText(row.fileValue)})` : ""}`, row.pending !== undefined ? "attention" : undefined)];
|
|
673
769
|
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")];
|
|
674
770
|
const [header, ...body] = String(row.text).split("\n");
|
|
675
771
|
// The header goes through the stream's own tokenizer with the event's tone and kind, on the same tab-flattened
|
|
@@ -712,10 +808,12 @@ function taskHints(row: any): string[] {
|
|
|
712
808
|
if (review) hints.push("r review");
|
|
713
809
|
return hints;
|
|
714
810
|
}
|
|
811
|
+
const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
|
|
715
812
|
function panelContext(s: ConsoleState, row: any): string[] {
|
|
716
813
|
if (!row) return [];
|
|
814
|
+
if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
|
|
717
815
|
if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
|
|
718
|
-
if (s.panel === 3) return taskHints(row);
|
|
816
|
+
if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
|
|
719
817
|
if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
|
|
720
818
|
return [];
|
|
721
819
|
}
|
|
@@ -734,7 +832,7 @@ function hint(s: ConsoleState, columns: number): string {
|
|
|
734
832
|
function header(s: ConsoleState, columns: number): Span[] {
|
|
735
833
|
const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
|
|
736
834
|
const tabs: Span[] = [];
|
|
737
|
-
for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events"].entries()) {
|
|
835
|
+
for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events", "Settings"].entries()) {
|
|
738
836
|
const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
|
|
739
837
|
tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
|
|
740
838
|
if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
|
|
@@ -778,6 +876,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
778
876
|
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
779
877
|
if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
780
878
|
if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
|
|
879
|
+
if (s.settingsEdit) {
|
|
880
|
+
const edit = s.settingsEdit;
|
|
881
|
+
prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
|
|
882
|
+
}
|
|
781
883
|
if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
|
|
782
884
|
const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
|
|
783
885
|
if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
|
|
@@ -790,6 +892,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
790
892
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
791
893
|
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"));
|
|
792
894
|
}
|
|
895
|
+
if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
|
|
793
896
|
const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
|
|
794
897
|
if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
|
|
795
898
|
const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
|
|
@@ -819,6 +922,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
819
922
|
const height = bodyHeight - noticeLines.length;
|
|
820
923
|
const lines: Span[][] = [];
|
|
821
924
|
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());
|
|
925
|
+
else if (s.settingsEdit) {
|
|
926
|
+
const edit = s.settingsEdit;
|
|
927
|
+
lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
|
|
928
|
+
}
|
|
822
929
|
else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
|
|
823
930
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
824
931
|
const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
@@ -835,6 +942,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
835
942
|
const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
|
|
836
943
|
const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
837
944
|
const rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now, stages)), columns) : undefined;
|
|
945
|
+
if (s.panel === 6) {
|
|
946
|
+
const row = selectedPanelRow(s);
|
|
947
|
+
const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
|
|
948
|
+
lines.push(...wrap(info, columns).slice(0, 2).map(text => [span(text, s.settingsProblem ? "failure" : "info")]));
|
|
949
|
+
if (s.pendingRestart?.length) lines.push(fitLine([span(`pending restart: ${s.pendingRestart.map(row => `${row.key}: ${row.reason}`).join("; ")}`, "attention")], columns));
|
|
950
|
+
}
|
|
838
951
|
if (s.panel === 3) {
|
|
839
952
|
const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
|
|
840
953
|
const main = s.tasksKnown === false && !progress.total ? "tasks loading..." : `${progress.counts.approved}/${progress.total} approved [${"#".repeat(done)}${".".repeat(20 - done)}] ${progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0}%`;
|
package/src/cli/console.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
4
4
|
import type { ControlClient } from "../hub/control-client.ts";
|
|
5
5
|
import { contextLine } from "./status-lines.ts";
|
|
6
6
|
import { renderTailEvent } from "./tail-render.ts";
|
|
7
|
-
import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow } from "./console-state.ts";
|
|
7
|
+
import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow, appendEvent, FIRST_KEYS, LAST_KEYS } from "./console-state.ts";
|
|
8
8
|
import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
|
|
9
9
|
import type { BusEvent } from "../hub/bus.ts";
|
|
10
10
|
import { consoleNeeds, attentionFacts } from "./attention.ts";
|
|
@@ -34,6 +34,17 @@ export interface ConsoleOptions {
|
|
|
34
34
|
runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
|
|
35
35
|
pollMs?: number;
|
|
36
36
|
}
|
|
37
|
+
/** Exact navigation sequences only (#309, #441); anything else in a multi-character read is a paste. */
|
|
38
|
+
const NAVIGATION = ["\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D", ...FIRST_KEYS, ...LAST_KEYS].filter(key => key.startsWith("\x1b"));
|
|
39
|
+
export function navigationPresses(text: string): string[] | undefined {
|
|
40
|
+
const keys: string[] = [];
|
|
41
|
+
for (let at = 0; at < text.length;) {
|
|
42
|
+
const key = NAVIGATION.find(k => text.startsWith(k, at));
|
|
43
|
+
if (!key) return undefined;
|
|
44
|
+
keys.push(key); at += key.length;
|
|
45
|
+
}
|
|
46
|
+
return keys.length ? keys : undefined;
|
|
47
|
+
}
|
|
37
48
|
export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
|
|
38
49
|
/** Console-only styling uses event structure; tail text and message bodies are never parsed for meaning. */
|
|
39
50
|
export function eventTone(e: BusEvent): Tone | undefined {
|
|
@@ -103,7 +114,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
103
114
|
/** `record: false` is for the console's own prints (the key table, a request viewed again): not events, so not in Events. */
|
|
104
115
|
const stream = (event: ConsoleEvent, record = true) => {
|
|
105
116
|
if (event.kind === "command") event = { ...event, text: terminalText(event.text).replace(/\t/g, " ") };
|
|
106
|
-
if (record) state
|
|
117
|
+
if (record) appendEvent(state, { ...event, text: terminalText(event.text) });
|
|
107
118
|
if (plain) {
|
|
108
119
|
const [header, ...body] = terminalText(event.text).split("\n");
|
|
109
120
|
terminal.write(paint([...(color ? streamTokens(header ?? "", event.tone, event.kind) : [{ text: header ?? "" }]), ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
|
|
@@ -178,11 +189,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
178
189
|
const replies = await Promise.all([
|
|
179
190
|
client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
|
|
180
191
|
...(panels ? [client.request({ t: "task", op: "hub_task_list", args: {} }, 3000), client.request({ t: "queue", op: "list" }, 3000), client.request({ t: "task", op: "hub_task_list", args: { ready: true } }, 3000)] : []),
|
|
192
|
+
...(panels && state.panel === 6 ? [client.request({ t: "settings_get" }, 3000)] : []),
|
|
181
193
|
]);
|
|
182
194
|
if (!active) return;
|
|
183
|
-
const [status, budget, tasks, queue, ready] = replies;
|
|
195
|
+
const [status, budget, tasks, queue, ready, settings] = replies;
|
|
184
196
|
captureConsoleSelection(state);
|
|
185
197
|
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
198
|
+
if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
|
|
199
|
+
if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
|
|
186
200
|
if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
|
|
187
201
|
if (status.status && status.ok !== false) {
|
|
188
202
|
state.attentionFacts = attentionFacts(status.status.attentionItems);
|
|
@@ -197,6 +211,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
197
211
|
state.tasksKnown = true;
|
|
198
212
|
} }
|
|
199
213
|
if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
|
|
214
|
+
if (settings) {
|
|
215
|
+
if (settings.ok && Array.isArray(settings.rows)) {
|
|
216
|
+
state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
|
|
217
|
+
state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
|
|
218
|
+
state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
|
|
219
|
+
} else state.settingsProblem = String(settings.error ?? "settings are unavailable");
|
|
220
|
+
}
|
|
200
221
|
reconcileDetail();
|
|
201
222
|
const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
|
|
202
223
|
draw();
|
|
@@ -221,6 +242,23 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
221
242
|
});
|
|
222
243
|
const effect = async (action: ConsoleEffect) => {
|
|
223
244
|
if (action.type === "exit") return stop();
|
|
245
|
+
if (action.type === "settings_preview") {
|
|
246
|
+
const response = await client.request(action.undo ? { t: "settings_preview", undo: true } : { t: "settings_preview", key: action.key, value: action.value }, 3000);
|
|
247
|
+
const edit = state.settingsEdit;
|
|
248
|
+
if (!active || state.mode !== "panels" || state.panel !== 6 || !edit || edit.phase !== "preview" || edit.token !== action.token || edit.undo !== action.undo || edit.key !== action.key || (!action.undo && JSON.stringify(edit.value) !== JSON.stringify(action.value))) return;
|
|
249
|
+
if (response.ok === false) { state.settingsEdit = undefined; notice(String(response.error ?? "setting preview refused")); }
|
|
250
|
+
else if (action.undo && (typeof response.key !== "string" || !response.key || response.key.trim() !== response.key || response.key.length > 160 || typeof response.undoDigest !== "string" || !/^[a-f0-9]{64}$/i.test(response.undoDigest))) {
|
|
251
|
+
state.settingsEdit = undefined; notice("undo preview is unavailable or changed; reload settings and review it again");
|
|
252
|
+
} else state.settingsEdit = { ...edit, ...(action.undo ? { key: response.key, undoDigest: response.undoDigest } : {}), phase: "reason", text: "", lines: [...(Array.isArray(response.lines) ? response.lines.map(String) : []), ...(Array.isArray(response.tasks) ? response.tasks.flatMap((task: any) => [String(task.before?.join("\n") ?? ""), String(task.after?.join("\n") ?? "")]) : []), ...(response.hidden ? [`${response.hidden} more open tasks are not shown`] : [])], ...(typeof response.confirm === "string" ? { confirm: response.confirm } : {}) };
|
|
253
|
+
draw(); return;
|
|
254
|
+
}
|
|
255
|
+
if (action.type === "settings_write") {
|
|
256
|
+
const result = await client.request({ t: action.undo ? "settings_undo" : "settings_set", key: action.key, ...(!action.undo ? { value: action.value } : { undoDigest: action.undoDigest }), reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
|
|
257
|
+
if (!active) return;
|
|
258
|
+
if (result.ok === false) notice(String(result.error ?? "setting write refused"));
|
|
259
|
+
else stream({ text: String(result.text ?? "setting saved"), kind: "settings" });
|
|
260
|
+
await refresh(); draw(); return;
|
|
261
|
+
}
|
|
224
262
|
if (action.type === "permission_default") {
|
|
225
263
|
const result = await client.request({ t: "permission_default", peer: action.peer, confirmed: action.confirmed }, 35_000);
|
|
226
264
|
if (!active) return;
|
|
@@ -256,12 +294,13 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
256
294
|
};
|
|
257
295
|
const handle = (key: string) => {
|
|
258
296
|
if (!active) return;
|
|
297
|
+
const priorPanel = state.panel;
|
|
259
298
|
const result = reduceConsole(state, key); state = result.state;
|
|
260
299
|
state.attentionMessages = []; state.attentionSelection = key === "n" ? state.attentionSelection : undefined;
|
|
261
300
|
if (state.mode === "panels" && (plain || columns < 80 || rows < 24)) { state.mode = "stream"; notice("panels need at least 80x24"); }
|
|
262
301
|
for (const action of result.effects) void effect(action).catch(error => stop(`Console error: ${String(error.message)}`));
|
|
263
302
|
draw();
|
|
264
|
-
if (key === "\t") void refresh();
|
|
303
|
+
if (key === "\t" || state.panel !== priorPanel) void refresh();
|
|
265
304
|
};
|
|
266
305
|
client.onPush = msg => {
|
|
267
306
|
if (!active) return;
|
|
@@ -299,14 +338,21 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
299
338
|
terminal.raw(true);
|
|
300
339
|
removers.push(terminal.onData(text => {
|
|
301
340
|
// A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
|
|
302
|
-
// Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is
|
|
303
|
-
// key presses (key repeat, a slow link), not a paste (#309): each
|
|
304
|
-
|
|
305
|
-
|
|
341
|
+
// Under the modal key table it does nothing at all. A read holding nothing but arrow or Home/End sequences is
|
|
342
|
+
// that many key presses (key repeat, a slow link), not a paste (#309, #441): each reaches the reducer on its own.
|
|
343
|
+
const presses = navigationPresses(text);
|
|
344
|
+
if (Array.from(text).length > 1 && presses?.length !== 1) {
|
|
345
|
+
if (presses) { for (const key of presses) handle(key); }
|
|
306
346
|
else {
|
|
307
347
|
state.attentionMessages = []; state.attentionSelection = undefined;
|
|
308
348
|
if (state.help && state.mode === "panels") { draw(); return; }
|
|
309
349
|
if (state.confirm?.type === "permission_default") { draw(); return; }
|
|
350
|
+
if (state.settingsEdit) {
|
|
351
|
+
const edit = state.settingsEdit;
|
|
352
|
+
if (edit.phase === "value" || edit.phase === "reason") state.settingsEdit = { ...edit, text: (edit.text + terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/[\r\n]/g, " ")).slice(0, 1000) };
|
|
353
|
+
else state.settingsEdit = undefined;
|
|
354
|
+
draw(); return;
|
|
355
|
+
}
|
|
310
356
|
state.confirm = undefined; state.optionChoice = undefined; state.editing = true;
|
|
311
357
|
state.input += terminalText(text.replace(/\x1b\[20[01]~/g, "")).replace(/\n/g, " "); draw();
|
|
312
358
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -33,6 +33,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
33
33
|
["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
|
|
34
34
|
["ahub upgrade [--to <version>] [--dry-run] [--yes]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
|
|
35
35
|
["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
|
|
36
|
+
["ahub recovery reattach <operation-id> codex|claude|pi", "person only: repair that blocked peer with its recorded session; leaves the rest for recovery resume"],
|
|
36
37
|
["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
37
38
|
["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
38
39
|
["ahub daemon [--unattended]", "internal: the detached hub process that ahub up or the dashboard manager starts"],
|
|
@@ -61,7 +62,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
61
62
|
["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
|
|
62
63
|
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
|
|
63
64
|
["ahub settings [list] [--json] | get <key>", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
|
|
64
|
-
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo", "human only: change
|
|
65
|
+
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
|
|
65
66
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
66
67
|
["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
|
|
67
68
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
|
|
@@ -80,6 +81,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
80
81
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
81
82
|
["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; --revision refuses stale console actions"],
|
|
82
83
|
["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
|
|
84
|
+
["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
|
|
83
85
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
84
86
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
85
87
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
+
import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
|
|
1
2
|
import { isSupportedPeer } from "../hub/peer-catalog.ts";
|
|
2
3
|
|
|
3
4
|
/** Recovery reads this declaration before delegating to another package's CLI. */
|
|
4
5
|
export const LIVE_TEST_SUPPORTED = true;
|
|
5
6
|
|
|
7
|
+
/** Presentation prefixes cannot hide a person's recovery action from authorization. */
|
|
8
|
+
function recoveryAction(args: readonly string[]): string | undefined {
|
|
9
|
+
return args.find(arg => !recoveryPresentationFlag(arg));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attributed content; use peer tools or a person's terminal";
|
|
13
|
+
|
|
6
14
|
/** Person verdicts and user-authored workflow content are never delegated. */
|
|
7
|
-
export function liveTestRefusal(command: string, args: readonly string[]): boolean {
|
|
15
|
+
export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
|
|
8
16
|
if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
|
|
9
|
-
const sub = args.find(arg => arg !== "--json");
|
|
17
|
+
const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
|
|
10
18
|
return ["task", "role", "goal"].includes(command)
|
|
11
19
|
|| (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
|
|
12
20
|
|| (command === "memory" && sub !== "graph")
|
|
21
|
+
|| (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
|
|
13
22
|
|| (command === "board" && args[0] === "import") || (command === "sdlc" && args[0] === "record")
|
|
14
23
|
|| (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
|
|
15
24
|
}
|
|
@@ -42,6 +51,7 @@ export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
|
42
51
|
|
|
43
52
|
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
44
53
|
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
54
|
+
if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
|
|
45
55
|
if (command === "pi" && args.includes("--replace-session")) return "console";
|
|
46
56
|
if (command === "pet" && args.includes("--window")) return "console";
|
|
47
57
|
if (command === "permission" || command === "stop" || command === "watch") return "console";
|
|
@@ -77,11 +87,12 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
|
|
|
77
87
|
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
78
88
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
|
|
79
89
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
80
|
-
if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
|
|
90
|
+
if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
|
|
81
91
|
}
|
|
82
92
|
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
83
93
|
}
|
|
84
94
|
|
|
85
|
-
export function peerCommandRefusal(peer: string, command: string): string {
|
|
86
|
-
|
|
95
|
+
export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
96
|
+
const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
|
|
97
|
+
return `${peer} cannot run ahub ${command}; the person runs it in ahub console or a terminal. Refused because inherited agent markers${markers.length ? ` (${markers.join(", ")})` : ""} identify this shell as an agent. A ! command in an agent session runs as that agent; use a separate terminal outside the agent session`;
|
|
87
98
|
}
|
package/src/cli/jcode-mcp.ts
CHANGED
|
@@ -21,7 +21,7 @@ const map = (doc: Document): Document => (doc.mcpServers ?? doc.servers ?? {}) a
|
|
|
21
21
|
const owned = (entry: unknown, cwd: string): entry is Document => object(entry) && object(entry.env) && entry.env.AGENTHUB_MODE === "tools" && entry.env.AGENTHUB_PEER_ID === "jcode" && entry.env.AGENTHUB_PROJECT_DIR === cwd;
|
|
22
22
|
|
|
23
23
|
export function jcodeMcpEntry(cwd: string, stateDir: string, bundle: string) {
|
|
24
|
-
return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: "jcode", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
|
|
24
|
+
return { command: "bun", args: ["run", bundle], shared: false, env: { AGENTHUB_MODE: "tools", AGENTHUB_DELIVERY_ONLY: "true", AGENTHUB_PEER_ID: "jcode", AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } };
|
|
25
25
|
}
|
|
26
26
|
/** The native loader reads later project files last. Never shadow a foreign definition. */
|
|
27
27
|
function inspect(cwd: string): Document {
|