@staix/agent-hub 0.12.29 → 0.12.31
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 +39 -0
- package/README.md +92 -218
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +3 -2
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +7 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +14 -0
- package/docs/agent-notes/tests.md +6 -3
- package/docs/memory.md +70 -152
- package/docs/operations.md +317 -25
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +24 -2
- package/docs/security.md +42 -2
- package/docs/smoke.md +11 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- 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/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +129 -62
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +145 -29
- package/src/cli/console.ts +40 -10
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +4 -3
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +43 -14
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +19 -2
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +204 -63
- package/src/cli/upgrade.ts +57 -24
- package/src/hub/board.ts +83 -24
- package/src/hub/bus.ts +108 -38
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +529 -89
- 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 +31 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +14 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +370 -71
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +3 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/ui/index.html +12 -9
- 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/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- package/src/memory/sync.ts +0 -145
|
@@ -108,7 +108,7 @@ export class LocalPeer extends BasePeer {
|
|
|
108
108
|
await this.requireBudget(envs, "model_calls");
|
|
109
109
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
110
110
|
if (signal.aborted) throw new Error("turn cancelled before model request");
|
|
111
|
-
const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
111
|
+
const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
112
112
|
const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
|
|
113
113
|
this.recordUsage(result, model, task);
|
|
114
114
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
@@ -378,7 +378,7 @@ export class LocalPeer extends BasePeer {
|
|
|
378
378
|
const route = policy?.route ?? this.opts.route;
|
|
379
379
|
const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
|
|
380
380
|
const task = policy?.task === undefined ? undefined : Number(policy.task);
|
|
381
|
-
const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
381
|
+
const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
382
382
|
const signal = this.abort!.signal;
|
|
383
383
|
const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
|
|
384
384
|
const routed = await this.hubCall(route, messages, policy, signal);
|
package/src/adapters/pi.ts
CHANGED
|
@@ -146,6 +146,11 @@ export class PiPeer extends BasePeer {
|
|
|
146
146
|
if (this.opts.mode === "headless") return !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
|
|
147
147
|
return this.stopping && !this.ownerClaimed && !!this.ownerPid && processLiveness(this.ownerPid, this.ownerSignature) === "gone";
|
|
148
148
|
}
|
|
149
|
+
/** Stable across turns, invalidated when the verified native owner/session changes. Not caller supplied. */
|
|
150
|
+
get sessionGeneration(): string | undefined {
|
|
151
|
+
return this.ownerClaimed && this.ownerPid && this.ownerSignature ? `${this.ownerPid}:${this.ownerSignature}:${this.sessionId ?? ""}` : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
149
154
|
recoveryMetadata(): Record<string, unknown> {
|
|
150
155
|
const exists = !!this.sessionFile && existsSync(this.sessionFile);
|
|
151
156
|
if (exists) { this.persistedOnce = true; this.verifiedEmptyResume = undefined; }
|
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,6 +2,7 @@ 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";
|
|
@@ -13,9 +14,10 @@ export interface Approval {
|
|
|
13
14
|
export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
|
|
14
15
|
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
15
16
|
export interface ConsoleState {
|
|
17
|
+
features?: readonly string[];
|
|
16
18
|
/** Older embedding callers omit choices and retain the original STAI/off toggle. */
|
|
17
19
|
petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
|
|
18
|
-
attentionFacts?: { kind: "review" | "offline"; id: number; peer?: string }[];
|
|
20
|
+
attentionFacts?: { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
|
|
19
21
|
attentionFingerprint?: string;
|
|
20
22
|
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
23
|
mode: "stream" | "panels"; panel: number; selection: number;
|
|
@@ -29,7 +31,9 @@ export interface ConsoleState {
|
|
|
29
31
|
approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
|
|
30
32
|
tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
31
33
|
peerFilter?: string; kindFilter?: string; project?: string;
|
|
32
|
-
|
|
34
|
+
taskSelection?: number; queueSelection?: string;
|
|
35
|
+
actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean };
|
|
36
|
+
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string } | { type: "permission_default"; peer: string; source: string };
|
|
33
37
|
optionChoice?: string;
|
|
34
38
|
modeChoice?: string;
|
|
35
39
|
permissionDefaults: PermissionDefault[];
|
|
@@ -313,14 +317,17 @@ function approvalFields(a: Approval): [string, unknown][] {
|
|
|
313
317
|
export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
|
|
314
318
|
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`;
|
|
315
319
|
}
|
|
320
|
+
/** #441: Home/End as terminals send them, plus `<` and `>` for keyboards without those keys. */
|
|
321
|
+
export const FIRST_KEYS = ["<", "\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"];
|
|
322
|
+
export const LAST_KEYS = [">", "\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"];
|
|
316
323
|
const KEYS: [string, string[]][] = [
|
|
317
324
|
["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
|
|
318
325
|
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
319
|
-
["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
|
|
326
|
+
["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"]],
|
|
320
327
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
321
|
-
["Peers", ["p pause", "r resume", "m permission mode"]],
|
|
322
|
-
["Tasks", ["a assign (then y)", "r review"]],
|
|
323
|
-
["Queue", ["r resolve (reason, then y)"]],
|
|
328
|
+
["Peers", ["p pause", "r resume", "m permission mode", "l make planner (then y)"]],
|
|
329
|
+
["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
|
|
330
|
+
["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
|
|
324
331
|
["Events", ["f peer filter", "g kind filter"]],
|
|
325
332
|
];
|
|
326
333
|
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
@@ -352,7 +359,7 @@ function bound(s: ConsoleState): ConsoleState {
|
|
|
352
359
|
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
353
360
|
return s;
|
|
354
361
|
}
|
|
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"]);
|
|
362
|
+
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"]);
|
|
356
363
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
357
364
|
export function parseConsoleCommand(input: string): string[] {
|
|
358
365
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -372,7 +379,7 @@ export function parseConsoleCommand(input: string): string[] {
|
|
|
372
379
|
}
|
|
373
380
|
export function commandNeedsConfirmation(args: string[]): boolean {
|
|
374
381
|
const positional = args.filter(arg => arg !== "--yes");
|
|
375
|
-
return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1]
|
|
382
|
+
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");
|
|
376
383
|
}
|
|
377
384
|
export function panelRows(s: ConsoleState): any[] {
|
|
378
385
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
@@ -384,9 +391,49 @@ export function panelRows(s: ConsoleState): any[] {
|
|
|
384
391
|
if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
|
|
385
392
|
return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
|
|
386
393
|
}
|
|
394
|
+
/** #441: Events follow the newest row while the selection is on the last one; a row the person moved to stays put. */
|
|
395
|
+
export function appendEvent(s: ConsoleState, event: ConsoleEvent, cap = 1000): void {
|
|
396
|
+
if (s.panel !== 5) { s.events = [...s.events, event].slice(-cap); return; }
|
|
397
|
+
const before = panelRows(s), held = before[s.selection], following = !held || s.selection >= before.length - 1;
|
|
398
|
+
s.events = [...s.events, event].slice(-cap);
|
|
399
|
+
const rows = panelRows(s);
|
|
400
|
+
s.selection = following ? Math.max(0, rows.length - 1) : Math.max(0, rows.indexOf(held));
|
|
401
|
+
}
|
|
402
|
+
const newestEvent = (s: ConsoleState) => Math.max(0, panelRows(s).length - 1);
|
|
387
403
|
export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
|
|
388
|
-
/** The selected row:
|
|
389
|
-
const selection = (s: ConsoleState) =>
|
|
404
|
+
/** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
|
|
405
|
+
const selection = (s: ConsoleState) => {
|
|
406
|
+
if (s.panel === 2) return s.approvals.findIndex(a => a.id === s.approvalId);
|
|
407
|
+
if (s.panel === 3 && s.taskSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.taskSelection);
|
|
408
|
+
if (s.panel === 4 && s.queueSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.queueSelection);
|
|
409
|
+
return s.selection;
|
|
410
|
+
};
|
|
411
|
+
export const selectedPanelRow = (s: ConsoleState): any | undefined => panelRows(s)[selection(s)];
|
|
412
|
+
export function captureConsoleSelection(s: ConsoleState, byIndex = false): void {
|
|
413
|
+
const row = byIndex ? panelRows(s)[s.selection] : selectedPanelRow(s);
|
|
414
|
+
if (s.panel === 3 && row) s.taskSelection = Number(row.id);
|
|
415
|
+
if (s.panel === 4 && row) s.queueSelection = String(row.id);
|
|
416
|
+
}
|
|
417
|
+
const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
|
|
418
|
+
function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
|
|
419
|
+
const revisions = args.flatMap((arg, index) => arg === "--revision" ? [args[index + 1]] : []);
|
|
420
|
+
if (args[0] !== lock.prefix[0] || args[1] !== lock.prefix[1] || args[2] !== lock.id || revisions.length !== 1 || revisions[0] !== String(lock.revision)) {
|
|
421
|
+
throw new Error("the selected id and revision cannot be edited; start again from the row");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): string {
|
|
425
|
+
if (lock.prefix[0] === "queue") {
|
|
426
|
+
const action = commandFlag(args, "--action") ?? "resolve";
|
|
427
|
+
return `${action} delivery ${lock.id} at revision ${lock.revision}${action === "completed" ? "; this asserts external effects" : action === "discard" ? "; no task or native turn is cancelled" : ""}`;
|
|
428
|
+
}
|
|
429
|
+
if (lock.prefix[1] === "cancel") return `cancel task #${lock.id} at revision ${lock.revision}; native turns and worktrees are retained`;
|
|
430
|
+
const role = commandFlag(args, "--role") ?? "owner";
|
|
431
|
+
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" : ""}`;
|
|
432
|
+
const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
|
|
433
|
+
const resulting = role === "owner" ? "proposed" : lock.state;
|
|
434
|
+
const guidance = role === "reviewer" && lock.state !== "in_review" ? "; later completion without a reviewer may approve directly" : lock.state === "in_review" ? `; use ahub review ${lock.id}` : "";
|
|
435
|
+
return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
|
|
436
|
+
}
|
|
390
437
|
export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
|
|
391
438
|
const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
|
|
392
439
|
// A notice answers the key before this one; one that pruning raises now stays.
|
|
@@ -427,24 +474,29 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
427
474
|
}
|
|
428
475
|
if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
|
|
429
476
|
if (s.editing || s.input) {
|
|
430
|
-
if (key === "\x1b") { s.input = ""; s.editing = false; }
|
|
477
|
+
if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
|
|
431
478
|
else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
|
|
432
479
|
else if (key === "\x15") s.input = "";
|
|
433
480
|
else if (key === "\x1b[A" || key === "\x1b[B") {
|
|
434
481
|
s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
|
|
435
482
|
s.input = s.history[s.historyIndex] ?? "";
|
|
436
483
|
} else if (key === "\r" || key === "\n") {
|
|
437
|
-
const input = s.input; s.input = ""; s.editing = false;
|
|
484
|
+
const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
|
|
438
485
|
if (!input.trim()) return done();
|
|
439
486
|
try {
|
|
440
487
|
const parsed = parseConsoleCommand(input);
|
|
441
488
|
const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
|
|
442
489
|
if (args[0] === "queue" && args[1] === "resolve") {
|
|
443
|
-
const reason = args.indexOf("--reason");
|
|
490
|
+
const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
|
|
444
491
|
if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
|
|
492
|
+
if (!["completed", "retry", "discard"].includes(action ?? "")) throw new Error("queue resolve requires --action completed|retry|discard");
|
|
493
|
+
}
|
|
494
|
+
if (lock) {
|
|
495
|
+
validateLockedCommand(lock, args);
|
|
496
|
+
if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
|
|
445
497
|
}
|
|
446
498
|
s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
|
|
447
|
-
if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
|
|
499
|
+
if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args) } : {}) };
|
|
448
500
|
else effects.push({ type: "command", args });
|
|
449
501
|
} catch (error) { notify(s, String((error as Error).message), now); }
|
|
450
502
|
} else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
|
|
@@ -482,7 +534,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
482
534
|
const selected = rows.findIndex(row => row.id === item.id);
|
|
483
535
|
if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
|
|
484
536
|
}
|
|
485
|
-
if (navigated) { s.detail = undefined; s.requestDetail = undefined; }
|
|
537
|
+
if (navigated) { captureConsoleSelection(s, true); s.detail = undefined; s.requestDetail = undefined; }
|
|
486
538
|
else {
|
|
487
539
|
const command = attentionCommand(item);
|
|
488
540
|
const text = `${item.kind}${item.peer ? ` ${item.peer}` : ""}${item.id ? ` ${item.id}` : ""}: ${command}`;
|
|
@@ -493,7 +545,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
493
545
|
}
|
|
494
546
|
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
495
547
|
if (key === ":") { s.editing = true; return done(); }
|
|
496
|
-
const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
|
|
548
|
+
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(); };
|
|
497
549
|
if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
|
|
498
550
|
// Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
|
|
499
551
|
// digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
|
|
@@ -504,10 +556,24 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
504
556
|
return done();
|
|
505
557
|
}
|
|
506
558
|
if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
|
|
559
|
+
// #452: open the project dashboard or the pet window as a person's console command; both return at once.
|
|
560
|
+
if (s.mode === "panels" && (key === "w" || key === "o") && !s.optionChoice) {
|
|
561
|
+
effects.push({ type: "command", args: key === "w" ? ["ui"] : ["pet", "--window"] });
|
|
562
|
+
return done();
|
|
563
|
+
}
|
|
564
|
+
if (s.mode === "panels" && (FIRST_KEYS.includes(key) || LAST_KEYS.includes(key))) {
|
|
565
|
+
const first = FIRST_KEYS.includes(key);
|
|
566
|
+
if (s.detail !== undefined || s.requestDetail) { if (first) s.detailOffset = 0; return done(); }
|
|
567
|
+
s.selection = first ? 0 : Math.max(0, panelRows(s).length - 1);
|
|
568
|
+
if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
|
|
569
|
+
captureConsoleSelection(s, true);
|
|
570
|
+
return done();
|
|
571
|
+
}
|
|
507
572
|
if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
|
|
508
573
|
if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
|
|
509
574
|
s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
|
|
510
575
|
if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
|
|
576
|
+
captureConsoleSelection(s, true);
|
|
511
577
|
return done();
|
|
512
578
|
}
|
|
513
579
|
if (key === "[" || key === "]") {
|
|
@@ -537,10 +603,11 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
537
603
|
if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
|
|
538
604
|
if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
|
|
539
605
|
}
|
|
540
|
-
const row =
|
|
606
|
+
const row = selectedPanelRow(s);
|
|
541
607
|
// With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
|
|
542
608
|
// a detail that is only a message names no item.
|
|
543
|
-
const
|
|
609
|
+
const selected = s.detail === undefined ? row : typeof s.detail === "object" ? s.detail : undefined;
|
|
610
|
+
const item = selected?.id;
|
|
544
611
|
if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
|
|
545
612
|
if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
|
|
546
613
|
else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
|
|
@@ -556,17 +623,52 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
556
623
|
else s.modeChoice = peer;
|
|
557
624
|
return done();
|
|
558
625
|
}
|
|
626
|
+
// #455: the planner is a setting; the command runs only after y, as a person's terminal command.
|
|
627
|
+
if (s.panel === 1 && key === "l") {
|
|
628
|
+
const peer = String(item);
|
|
629
|
+
if (!(PLANNER_PEERS as readonly string[]).includes(peer)) notify(s, `${peer} cannot plan: the planner is one of ${PLANNER_PEERS.join(", ")}`, now);
|
|
630
|
+
else if (s.peers[peer]?.planner === "setting") notify(s, `${peer} is already the pinned planner`, now, "attention");
|
|
631
|
+
else s.confirm = { type: "command", args: ["settings", "set", "planner", peer], prompt: `make ${peer} the planner` };
|
|
632
|
+
return done();
|
|
633
|
+
}
|
|
559
634
|
const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
|
|
560
|
-
s.panel === 3 && key === "
|
|
561
|
-
s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
|
|
635
|
+
s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
|
|
562
636
|
if (prefill) { s.input = prefill; s.editing = true; return done(); }
|
|
637
|
+
if (s.panel === 3 && key === "c") {
|
|
638
|
+
if (!s.features?.includes("task_cancel")) { notify(s, "task cancellation requires a newer hub", now); return done(); }
|
|
639
|
+
const revision = selected.flowRevision;
|
|
640
|
+
if (!["proposed", "in_progress", "in_review", "changes_requested", "backlog"].includes(selected.state)) return done();
|
|
641
|
+
if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
|
|
642
|
+
s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state) };
|
|
643
|
+
s.input = `task cancel ${item} --revision ${revision} --reason `; s.editing = true; return done();
|
|
644
|
+
}
|
|
645
|
+
if (s.panel === 3 && (key === "a" || key === "u")) {
|
|
646
|
+
if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
|
|
647
|
+
const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
|
|
648
|
+
if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
|
|
649
|
+
if (key === "u" && (role === "owner" && !selected.owner && !selected.reserved || role === "reviewer" && !selected.reviewer)) { notify(s, `task #${item}: no ${role} to unassign`, now); return done(); }
|
|
650
|
+
const listed = s.tasks.find(task => task.id === item), ready = listed?.ready ?? selected.ready;
|
|
651
|
+
const waiting = selected.state === "proposed" && ready !== true && Array.isArray(selected.deps) && selected.deps.length > 0;
|
|
652
|
+
s.actionLock = { prefix: ["task", key === "a" ? "assign" : "unassign"], id: String(item), revision, state: String(selected.state), ...(selected.owner ? { owner: String(selected.owner) } : {}), ...(selected.reserved ? { reserved: String(selected.reserved) } : {}), ...(selected.reviewer ? { reviewer: String(selected.reviewer) } : {}), waiting };
|
|
653
|
+
s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
|
|
654
|
+
s.editing = true; return done();
|
|
655
|
+
}
|
|
656
|
+
if (s.panel === 4 && key === "r") {
|
|
657
|
+
if (!["queued", "needs_review"].includes(selected.state)) return done();
|
|
658
|
+
const revision = selected.revision;
|
|
659
|
+
if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "queue row actions need a delivery revision; upgrade the hub", now); return done(); }
|
|
660
|
+
const action = selected.state === "queued" ? "discard" : "retry";
|
|
661
|
+
s.actionLock = { prefix: ["queue", "resolve"], id: String(item), revision, state: String(selected.state) };
|
|
662
|
+
s.input = `queue resolve ${item} --action ${action} --revision ${revision} --reason `;
|
|
663
|
+
s.editing = true; return done();
|
|
664
|
+
}
|
|
563
665
|
}
|
|
564
666
|
if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
|
|
565
667
|
const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
|
|
566
668
|
const values = [...new Set(s.events.map(e => e[field]).filter((x): x is string => !!x))];
|
|
567
669
|
const next = values[values.indexOf(current ?? "") + 1];
|
|
568
670
|
if (key === "f") s.peerFilter = next; else s.kindFilter = next;
|
|
569
|
-
s.selection =
|
|
671
|
+
s.selection = newestEvent(s); return done();
|
|
570
672
|
}
|
|
571
673
|
if (s.mode === "stream" && !key.startsWith("\x1b")) { s.editing = true; s.input = terminalText(key).replace(/\n/g, ""); }
|
|
572
674
|
return done();
|
|
@@ -594,7 +696,7 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
594
696
|
const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
|
|
595
697
|
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
596
698
|
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
597
|
-
return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.rostered === false ? `${row.state} off roster` : row.state, stateTone(row.state)),
|
|
699
|
+
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)),
|
|
598
700
|
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
599
701
|
span(row.pullOnly && row.id === "kimi" ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
600
702
|
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
@@ -608,9 +710,9 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
608
710
|
const at = last?.at ?? row.updated ?? row.created;
|
|
609
711
|
const failed = last?.event === "check failed" || last?.event === "failed";
|
|
610
712
|
const stage = stages?.get(row.id);
|
|
611
|
-
const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
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)),
|
|
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))];
|
|
713
|
+
const meter = stage?.cancelled ? "[----] cancelled" : stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
714
|
+
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)),
|
|
715
|
+
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))];
|
|
614
716
|
}
|
|
615
717
|
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")];
|
|
616
718
|
const [header, ...body] = String(row.text).split("\n");
|
|
@@ -646,6 +748,21 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
|
|
|
646
748
|
return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
|
|
647
749
|
}));
|
|
648
750
|
}
|
|
751
|
+
function taskHints(row: any): string[] {
|
|
752
|
+
if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)) return [];
|
|
753
|
+
const review = row.state === "in_review";
|
|
754
|
+
const hints = [review ? "a reviewer" : "a assign"];
|
|
755
|
+
if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
|
|
756
|
+
if (review) hints.push("r review");
|
|
757
|
+
return hints;
|
|
758
|
+
}
|
|
759
|
+
function panelContext(s: ConsoleState, row: any): string[] {
|
|
760
|
+
if (!row) return [];
|
|
761
|
+
if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
|
|
762
|
+
if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
|
|
763
|
+
if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
|
|
764
|
+
return [];
|
|
765
|
+
}
|
|
649
766
|
/** The footer's key hint: only keys that act in this mode, panel and state. */
|
|
650
767
|
function hint(s: ConsoleState, columns: number): string {
|
|
651
768
|
const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
|
|
@@ -653,9 +770,8 @@ function hint(s: ConsoleState, columns: number): string {
|
|
|
653
770
|
if (s.help) return "? or Esc close q quit";
|
|
654
771
|
const approvals = s.panel === 2 ? pending : [];
|
|
655
772
|
if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
|
|
656
|
-
const rows = panelRows(s); const row =
|
|
657
|
-
const
|
|
658
|
-
const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
|
|
773
|
+
const rows = panelRows(s); const row = selectedPanelRow(s);
|
|
774
|
+
const text = [...approvals, ...panelContext(s, row), ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
|
|
659
775
|
return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
|
|
660
776
|
}
|
|
661
777
|
/** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
|
|
@@ -705,7 +821,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
705
821
|
const permission = selectedApproval(s);
|
|
706
822
|
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
707
823
|
if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
708
|
-
if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
|
|
824
|
+
if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
|
|
709
825
|
if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
|
|
710
826
|
const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
|
|
711
827
|
if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
|
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,
|
|
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 {
|
|
@@ -82,7 +93,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
82
93
|
let lastAct = 0; let lastBell = -Infinity;
|
|
83
94
|
let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
|
|
84
95
|
let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
|
|
85
|
-
let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
|
|
96
|
+
let polling = false; let refreshPending = false; let childRunning = false; let cancelChild: (() => void) | undefined;
|
|
86
97
|
const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
|
|
87
98
|
const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
|
|
88
99
|
let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
|
|
@@ -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");
|
|
@@ -159,8 +170,20 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
159
170
|
safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
|
|
160
171
|
resolveDone();
|
|
161
172
|
};
|
|
173
|
+
const reconcileDetail = () => {
|
|
174
|
+
if (state.detail === undefined || typeof state.detail !== "object" || (state.panel !== 3 && state.panel !== 4)) return;
|
|
175
|
+
const id = state.detail.id;
|
|
176
|
+
if (id === undefined || id === null) return;
|
|
177
|
+
const key = state.panel === 3 ? "flowRevision" : "revision", rows = state.panel === 3 ? state.tasks : state.queue;
|
|
178
|
+
const fresh = rows.find(row => String(row.id) === String(id));
|
|
179
|
+
if (!fresh || state.detail[key] !== fresh[key]) {
|
|
180
|
+
state.detail = undefined; state.detailOffset = 0;
|
|
181
|
+
notice(`detail ${id} changed or is no longer available`);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
162
184
|
const refresh = async () => {
|
|
163
|
-
if (polling || !active)
|
|
185
|
+
if (polling || !active) { if (active) refreshPending = true; return; }
|
|
186
|
+
polling = true;
|
|
164
187
|
try {
|
|
165
188
|
const panels = state.mode === "panels" && !plain;
|
|
166
189
|
const replies = await Promise.all([
|
|
@@ -169,7 +192,9 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
169
192
|
]);
|
|
170
193
|
if (!active) return;
|
|
171
194
|
const [status, budget, tasks, queue, ready] = replies;
|
|
195
|
+
captureConsoleSelection(state);
|
|
172
196
|
if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
|
|
197
|
+
if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
|
|
173
198
|
if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
|
|
174
199
|
if (status.status && status.ok !== false) {
|
|
175
200
|
state.attentionFacts = attentionFacts(status.status.attentionItems);
|
|
@@ -184,10 +209,14 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
184
209
|
state.tasksKnown = true;
|
|
185
210
|
} }
|
|
186
211
|
if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
|
|
212
|
+
reconcileDetail();
|
|
187
213
|
const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
|
|
188
214
|
draw();
|
|
189
215
|
} catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
|
|
190
|
-
finally {
|
|
216
|
+
finally {
|
|
217
|
+
polling = false;
|
|
218
|
+
if (refreshPending) { refreshPending = false; void refresh(); }
|
|
219
|
+
}
|
|
191
220
|
};
|
|
192
221
|
const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
|
|
193
222
|
const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...(options.liveTest ? ["--live-test"] : []), ...args], {
|
|
@@ -223,7 +252,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
223
252
|
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);
|
|
224
253
|
// A late reply is for the panel and row it was asked for: once the person has moved on, it would show one item
|
|
225
254
|
// while the keys act on another, so it is dropped.
|
|
226
|
-
if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(
|
|
255
|
+
if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(selectedPanelRow(state)?.id) !== action.id) return;
|
|
227
256
|
state.detailOffset = 0;
|
|
228
257
|
state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? labeled(String(result.text)) : result.delivery ?? "delivery not found";
|
|
229
258
|
draw(); return;
|
|
@@ -282,10 +311,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
282
311
|
terminal.raw(true);
|
|
283
312
|
removers.push(terminal.onData(text => {
|
|
284
313
|
// A pasted chunk can only edit text, never approve, confirm or run commands. Bracketed-paste delimiters are stripped.
|
|
285
|
-
// Under the modal key table it does nothing at all. A read holding nothing but arrow sequences is
|
|
286
|
-
// key presses (key repeat, a slow link), not a paste (#309): each
|
|
287
|
-
|
|
288
|
-
|
|
314
|
+
// Under the modal key table it does nothing at all. A read holding nothing but arrow or Home/End sequences is
|
|
315
|
+
// that many key presses (key repeat, a slow link), not a paste (#309, #441): each reaches the reducer on its own.
|
|
316
|
+
const presses = navigationPresses(text);
|
|
317
|
+
if (Array.from(text).length > 1 && presses?.length !== 1) {
|
|
318
|
+
if (presses) { for (const key of presses) handle(key); }
|
|
289
319
|
else {
|
|
290
320
|
state.attentionMessages = []; state.attentionSelection = undefined;
|
|
291
321
|
if (state.help && state.mode === "panels") { draw(); return; }
|
package/src/cli/help.ts
CHANGED
|
@@ -65,7 +65,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
65
65
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
66
66
|
["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
|
|
67
67
|
["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
|
|
68
|
-
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "
|
|
68
|
+
["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show"],
|
|
69
69
|
]],
|
|
70
70
|
["Tasks and review", [
|
|
71
71
|
["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
|
|
@@ -78,7 +78,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
78
78
|
["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
|
|
79
79
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
80
80
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
81
|
-
["ahub task assign <id> <peer> [--role owner|reviewer]", "name the owner (default) or reviewer; a task that still waits reserves its owner"],
|
|
81
|
+
["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
|
+
["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"],
|
|
82
83
|
["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)"],
|
|
83
84
|
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
|
|
84
85
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -77,11 +77,12 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
|
|
|
77
77
|
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
78
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
|
|
79
79
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
80
|
-
if (sub && ["propose", "show", "assign", "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", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
|
|
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}`;
|
|
81
81
|
}
|
|
82
82
|
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export function peerCommandRefusal(peer: string, command: string): string {
|
|
86
|
-
|
|
85
|
+
export function peerCommandRefusal(peer: string, command: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
86
|
+
const markers = ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "AGENTHUB_PEER_ID", "CODEX_THREAD_ID"].filter(name => env[name] !== undefined);
|
|
87
|
+
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
88
|
}
|
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 {
|
package/src/cli/main.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// Keep this entrypoint plain JavaScript: Node must fail before loading TypeScript or bun:sqlite.
|
|
3
3
|
const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(process.versions.bun ?? "");
|
|
4
|
-
if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2])
|
|
5
|
-
console.error("ahub: Bun >=1.
|
|
4
|
+
if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number(version[2]) > 4 || (Number(version[2]) === 4 && Number(version[3]) >= 2))))) {
|
|
5
|
+
console.error("ahub: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
|
|
6
6
|
process.exit(1);
|
|
7
7
|
}
|
|
8
8
|
|