@staix/agent-hub 0.12.42 → 0.12.44
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 +30 -0
- package/README.md +5 -4
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +11 -4
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -2
- package/docs/operations.md +159 -26
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +7 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +808 -255
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +50 -16
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/completion.ts +173 -0
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +116 -24
- package/src/cli/console.ts +9 -6
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +84 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +33 -21
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +471 -338
- package/src/hub/hub-tools.ts +37 -1
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +51 -17
- package/src/hub/snapshots.ts +276 -17
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +101 -19
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- 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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
|
+
import type { InstancesConfig } from "../hub/instances.ts";
|
|
3
|
+
import { requireActiveInstance, requirePeerInstances } from "./launch.ts";
|
|
4
|
+
import { configuredAcpPreview } from "./preview.ts";
|
|
5
|
+
|
|
6
|
+
export interface ConfiguredAcpSelection { peer: string; mode: "headless"; sessionId?: string; preview: boolean }
|
|
7
|
+
export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig): ConfiguredAcpSelection {
|
|
8
|
+
const definition = catalog.definitionOf(kind);
|
|
9
|
+
if (!definition?.permission.genericAcp) throw new Error("unknown configured ACP kind");
|
|
10
|
+
const values = new Map<string, string>();
|
|
11
|
+
let preview = false;
|
|
12
|
+
for (let index = 0; index < raw.length; index++) {
|
|
13
|
+
const arg = raw[index]!;
|
|
14
|
+
if (arg === "--print-command" || arg === "--dry-run") { if (preview) throw new Error("preview option may appear only once"); preview = true; continue; }
|
|
15
|
+
const equal = arg.indexOf("="), flag = equal < 0 ? arg : arg.slice(0, equal);
|
|
16
|
+
if (!["--as", "--mode", "--session-id"].includes(flag) || values.has(flag)) throw new Error("configured ACP accepts only --mode headless, --as, --session-id and one preview option");
|
|
17
|
+
const value = equal < 0 ? raw[++index] : arg.slice(equal + 1);
|
|
18
|
+
if (!value || value.startsWith("--") || Buffer.byteLength(value) > 4096 || /[\p{Cc}\p{Cf}]/u.test(value)) throw new Error(`${flag} needs a bounded control-free value`);
|
|
19
|
+
values.set(flag, value);
|
|
20
|
+
}
|
|
21
|
+
if (values.has("--mode") && values.get("--mode") !== "headless") throw new Error("configured ACP supports headless only");
|
|
22
|
+
const peer = values.get("--as") ?? kind;
|
|
23
|
+
if (values.has("--as") && (!instances || !Object.hasOwn(instances, peer) || instances[peer]?.kind !== kind || !catalog.definitionOf(peer, instances))) throw new Error("--as must name a declared instance of this configured kind");
|
|
24
|
+
return { peer, mode: "headless", ...(values.has("--session-id") ? { sessionId: values.get("--session-id")! } : {}), preview };
|
|
25
|
+
}
|
|
26
|
+
export interface ConfiguredAcpControl {
|
|
27
|
+
features: readonly string[];
|
|
28
|
+
request(message: Record<string, unknown>, timeout?: number): Promise<any>;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void): Promise<void> {
|
|
32
|
+
if (!hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
|
|
33
|
+
if (peer !== kind) requirePeerInstances(hub.features);
|
|
34
|
+
const reply = await hub.request({ t: "status" });
|
|
35
|
+
const active = reply.status?.configuredAdapters;
|
|
36
|
+
if (!Array.isArray(active) || !active.includes(kind)) throw new Error(`running hub has not loaded ${kind}; restart it after declaring the adapter`);
|
|
37
|
+
if (peer !== kind) requireActiveInstance(reply.status, peer);
|
|
38
|
+
const namespace = `adapters.${kind}`;
|
|
39
|
+
const retained = [reply.status?.retainedConfig, reply.status?.pendingRestart].some(rows => Array.isArray(rows) && rows.some(row => {
|
|
40
|
+
const key = row && typeof row === "object" ? row.key : undefined;
|
|
41
|
+
return typeof key === "string" && (key === "adapters" || key === namespace || key.startsWith(`${namespace}.`));
|
|
42
|
+
}));
|
|
43
|
+
if (retained) warn?.(`${kind}: configured adapter changes are retained; this start uses the running definition. Restart the hub to apply the file's definition.`);
|
|
44
|
+
}
|
|
45
|
+
export async function runConfiguredAcpEntry(context: {
|
|
46
|
+
kind: string; raw: readonly string[]; catalog: ProjectAdapterCatalog; instances?: InstancesConfig;
|
|
47
|
+
connect(): Promise<ConfiguredAcpControl>; print(text: string): void; operationId?: string;
|
|
48
|
+
}): Promise<void> {
|
|
49
|
+
const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances);
|
|
50
|
+
if (selected.preview) { context.print(JSON.stringify(configuredAcpPreview(context.kind, selected.peer, selected.sessionId !== undefined), null, 2)); return; }
|
|
51
|
+
const hub = await context.connect();
|
|
52
|
+
try {
|
|
53
|
+
await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print);
|
|
54
|
+
const reply = await hub.request({ t: "start", peer: selected.peer, args: { mode: "headless", ...(selected.sessionId ? { sessionId: selected.sessionId } : {}) }, ...(context.operationId ? { operationId: context.operationId } : {}) }, 70_000);
|
|
55
|
+
if (!reply.ok) throw new Error(typeof reply.error === "string" ? reply.error : "configured ACP start refused");
|
|
56
|
+
context.print(reply.already ? `${selected.peer} is already attached` : `${selected.peer} attached (headless); use ahub say @${selected.peer} and the console for approvals`);
|
|
57
|
+
} finally { hub.close(); }
|
|
58
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -2,7 +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, definitionOf, nativeStreamTone } from "../adapters/registry.ts";
|
|
5
|
+
import { PLANNER_PEERS, definitionOf, nativeStreamTone, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
6
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
7
7
|
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
8
8
|
import { sanitize } from "../hub/envelope.ts";
|
|
@@ -16,6 +16,8 @@ export interface PermissionDefault { peer: string; mode: "never-ask"; source: st
|
|
|
16
16
|
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
17
17
|
export interface SettingsEdit {
|
|
18
18
|
token: number; key?: string; undo: boolean; phase: "value" | "preview" | "reason" | "confirm";
|
|
19
|
+
/** The existing editor can also be opened by the Peers WIP shortcut. */
|
|
20
|
+
panel?: 1 | 6;
|
|
19
21
|
text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
|
|
20
22
|
}
|
|
21
23
|
export interface ConsoleState {
|
|
@@ -38,11 +40,13 @@ export interface ConsoleState {
|
|
|
38
40
|
autoSelected?: boolean;
|
|
39
41
|
input: string; editing: boolean; history: string[]; historyIndex: number;
|
|
40
42
|
approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
|
|
43
|
+
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
44
|
+
backlogView?: boolean;
|
|
41
45
|
tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
42
46
|
peerFilter?: string; kindFilter?: string; project?: string;
|
|
43
47
|
taskSelection?: number; queueSelection?: string;
|
|
44
|
-
actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean };
|
|
45
|
-
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string } | { type: "permission_default"; peer: string; source: string };
|
|
48
|
+
actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean; syntax?: "planning" | "ownerless-return"; revisionField?: "planningRevision" | "flowRevision" };
|
|
49
|
+
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string; lock?: ConsoleState["actionLock"] } | { type: "permission_default"; peer: string; source: string };
|
|
46
50
|
optionChoice?: string;
|
|
47
51
|
modeChoice?: string;
|
|
48
52
|
permissionDefaults: PermissionDefault[];
|
|
@@ -87,7 +91,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
|
|
|
87
91
|
return true;
|
|
88
92
|
}
|
|
89
93
|
export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
|
|
90
|
-
return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
|
|
94
|
+
return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer, undefined, createProjectAdapterCatalog())}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
|
|
91
95
|
}
|
|
92
96
|
/** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
|
|
93
97
|
export function terminalText(value: unknown): string {
|
|
@@ -334,10 +338,10 @@ export const LAST_KEYS = [">", "\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"];
|
|
|
334
338
|
const KEYS: [string, string[]][] = [
|
|
335
339
|
["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
|
|
336
340
|
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
337
|
-
["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"]],
|
|
341
|
+
["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 (outside Peers)", "o pet window"]],
|
|
338
342
|
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
339
|
-
["Peers", ["p pause", "r resume", "m permission mode", "l make planner (then y)"]],
|
|
340
|
-
["Tasks", ["a assign/
|
|
343
|
+
["Peers", ["p pause", "r resume", "m permission mode", "w WIP limit", "l make planner (then y)"]],
|
|
344
|
+
["Tasks", ["b backlog/open", "n add backlog", "a assign/reserve (then y)", "u unassign (then y)", "r review (in review)", "p pull, e plan (backlog)", "R return to backlog", "c cancel (then y)"]],
|
|
341
345
|
["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
|
|
342
346
|
["Events", ["f peer filter", "g kind filter"]],
|
|
343
347
|
["Settings", ["6 Settings panel", "e edit", "u undo", "preview, reason, then y"]],
|
|
@@ -371,7 +375,7 @@ function bound(s: ConsoleState): ConsoleState {
|
|
|
371
375
|
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
372
376
|
return s;
|
|
373
377
|
}
|
|
374
|
-
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"]);
|
|
378
|
+
const COMMANDS = new Set(["status", "board", "task", "backlog", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "sdlc", "turns", "undo", "check-path", "report", "ui", "pet"]);
|
|
375
379
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
376
380
|
export function parseConsoleCommand(input: string): string[] {
|
|
377
381
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -391,12 +395,13 @@ export function parseConsoleCommand(input: string): string[] {
|
|
|
391
395
|
}
|
|
392
396
|
export function commandNeedsConfirmation(args: string[]): boolean {
|
|
393
397
|
const positional = args.filter(arg => arg !== "--yes");
|
|
394
|
-
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");
|
|
398
|
+
return (args[0] === "backlog" && args[1] !== "list") || (args[0] === "task" && args[1] === "propose" && args.includes("--backlog")) || (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");
|
|
395
399
|
}
|
|
396
400
|
export function panelRows(s: ConsoleState): any[] {
|
|
397
401
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
398
402
|
if (s.panel === 2) return s.approvals;
|
|
399
403
|
if (s.panel === 3) {
|
|
404
|
+
if (s.backlogView) return s.tasks.filter(t => t.state === "backlog").sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id);
|
|
400
405
|
const states = ["proposed", "in_progress", "in_review", "changes_requested"];
|
|
401
406
|
return s.tasks.filter(t => states.includes(t.state)).sort((a, b) => states.indexOf(a.state) - states.indexOf(b.state));
|
|
402
407
|
}
|
|
@@ -430,11 +435,20 @@ export function captureConsoleSelection(s: ConsoleState, byIndex = false): void
|
|
|
430
435
|
const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
|
|
431
436
|
function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
|
|
432
437
|
const revisions = args.flatMap((arg, index) => arg === "--revision" ? [args[index + 1]] : []);
|
|
433
|
-
|
|
438
|
+
const matches = lock.syntax === "planning" ? args[3] === String(lock.revision) : lock.syntax === "ownerless-return" ? args.length === 3 : revisions.length === 1 && revisions[0] === String(lock.revision);
|
|
439
|
+
if (args[0] !== lock.prefix[0] || args[1] !== lock.prefix[1] || args[2] !== lock.id || !matches) {
|
|
434
440
|
throw new Error("the selected id and revision cannot be edited; start again from the row");
|
|
435
441
|
}
|
|
436
442
|
}
|
|
443
|
+
function validateLockedRow(s: ConsoleState, lock: NonNullable<ConsoleState["actionLock"]>): void {
|
|
444
|
+
if (!lock.revisionField) return;
|
|
445
|
+
const row = s.tasks.find(row => String(row.id) === lock.id);
|
|
446
|
+
// Board rows omit a zero planning revision; flow revisions are always explicit in the console projection.
|
|
447
|
+
const revision = row && (lock.revisionField === "planningRevision" ? row.planningRevision ?? 0 : row.flowRevision);
|
|
448
|
+
if (!row || row.state !== lock.state || revision !== lock.revision) throw new Error("the selected task revision changed; reload and start again from the row");
|
|
449
|
+
}
|
|
437
450
|
function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): string {
|
|
451
|
+
if (lock.prefix[0] === "backlog") return `${lock.prefix[1]} task #${lock.id} at ${lock.revisionField === "planningRevision" ? "planning " : ""}revision ${lock.revision}`;
|
|
438
452
|
if (lock.prefix[0] === "queue") {
|
|
439
453
|
const action = commandFlag(args, "--action") ?? "resolve";
|
|
440
454
|
return `${action} delivery ${lock.id} at revision ${lock.revision}${action === "completed" ? "; this asserts external effects" : action === "discard" ? "; no task or native turn is cancelled" : ""}`;
|
|
@@ -457,6 +471,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
457
471
|
if (s.settingsEdit) {
|
|
458
472
|
const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
|
|
459
473
|
if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
|
|
474
|
+
const locked = s.settingsRows?.find(row => row.key === edit.key)?.locked;
|
|
475
|
+
if (locked) { s.settingsEdit = undefined; notify(s, locked, now); return done(); }
|
|
460
476
|
if (edit.phase === "preview") return done();
|
|
461
477
|
if (edit.phase === "confirm") {
|
|
462
478
|
s.settingsEdit = undefined;
|
|
@@ -493,6 +509,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
493
509
|
return done();
|
|
494
510
|
}
|
|
495
511
|
if (key === "y") {
|
|
512
|
+
if (confirm.type === "command" && confirm.lock) {
|
|
513
|
+
try { validateLockedRow(s, confirm.lock); } catch (error) { notify(s, (error as Error).message, now); return done(); }
|
|
514
|
+
}
|
|
496
515
|
if (confirm.type === "command") effects.push({ type: "command", args: confirm.args[0] === "permission" && confirm.args[2] === "never-ask" ? [...confirm.args.filter(arg => arg !== "--yes"), "--yes"] : confirm.args });
|
|
497
516
|
else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
|
|
498
517
|
effects.push({ type: "permit", id: confirm.id, option: confirm.option });
|
|
@@ -535,15 +554,23 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
535
554
|
}
|
|
536
555
|
if (lock) {
|
|
537
556
|
validateLockedCommand(lock, args);
|
|
557
|
+
validateLockedRow(s, lock);
|
|
558
|
+
if (lock.syntax === "planning" && args.length < (lock.prefix[1] === "plan" ? 6 : 5)) throw new Error("backlog row actions require a reason");
|
|
538
559
|
if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
|
|
539
560
|
}
|
|
540
561
|
s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
|
|
541
|
-
if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args) } : {}) };
|
|
562
|
+
if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args), ...(lock.revisionField ? { lock } : {}) } : {}) };
|
|
542
563
|
else effects.push({ type: "command", args });
|
|
543
564
|
} catch (error) { notify(s, String((error as Error).message), now); }
|
|
544
565
|
} else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
|
|
545
566
|
return done();
|
|
546
567
|
}
|
|
568
|
+
if (s.mode === "panels" && s.panel === 3 && key === "b") {
|
|
569
|
+
s.backlogView = !s.backlogView; s.selection = 0; s.taskSelection = undefined; s.detail = undefined; s.detailOffset = 0; return done();
|
|
570
|
+
}
|
|
571
|
+
if (s.mode === "panels" && s.panel === 3 && key === "n" && s.detail === undefined) {
|
|
572
|
+
s.input = "backlog add implement "; s.editing = true; return done();
|
|
573
|
+
}
|
|
547
574
|
if ((key === "n" || key === "P") && attentionKeyBlocked) return done();
|
|
548
575
|
if (key === "q") { effects.push({ type: "exit" }); return done(); }
|
|
549
576
|
if (key === "P" && !s.optionChoice && !s.help) {
|
|
@@ -572,9 +599,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
572
599
|
const selected = item.id === undefined ? -1 : rows.findIndex(row => row.id === item.id);
|
|
573
600
|
if (selected >= 0) { s.panel = 4; s.selection = selected; navigated = true; }
|
|
574
601
|
} else if (item.kind === "review" && s.mode === "panels") {
|
|
575
|
-
const rows = panelRows({ ...s, panel: 3 });
|
|
602
|
+
const rows = panelRows({ ...s, panel: 3, backlogView: false });
|
|
576
603
|
const selected = rows.findIndex(row => row.id === item.id);
|
|
577
|
-
if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
|
|
604
|
+
if (selected >= 0) { s.panel = 3; s.backlogView = false; s.selection = selected; navigated = true; }
|
|
578
605
|
}
|
|
579
606
|
if (navigated) { captureConsoleSelection(s, true); s.detail = undefined; s.requestDetail = undefined; }
|
|
580
607
|
else {
|
|
@@ -592,10 +619,23 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
592
619
|
// Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
|
|
593
620
|
// digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
|
|
594
621
|
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);
|
|
622
|
+
if (s.mode === "panels" && s.panel === 1 && key === "w") {
|
|
623
|
+
const peer = selectedPanelRow(s)?.id;
|
|
624
|
+
if (!peer || s.detail !== undefined) { notify(s, "select a peer row to edit its planner WIP limit", now); return done(); }
|
|
625
|
+
const wip = s.taskWip?.[peer];
|
|
626
|
+
if (!wip || s.peers[peer]?.rostered === false) { notify(s, `planner WIP is unavailable for ${peer}`, now); return done(); }
|
|
627
|
+
const row = s.settingsRows?.find(row => row.key === `tasks.wip_limits.${peer}`);
|
|
628
|
+
if (!row || s.settingsProblem) { notify(s, s.settingsProblem ?? "WIP setting is unavailable; refresh settings and try again", now); return done(); }
|
|
629
|
+
const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
|
|
630
|
+
s.settingsEdit = { token, panel: 1, key: row.key, undo: false, phase: "value", text: String(wip.wipLimit) };
|
|
631
|
+
return done();
|
|
632
|
+
}
|
|
595
633
|
if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
|
|
596
634
|
const row = selectedPanelRow(s);
|
|
597
635
|
if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
|
|
598
636
|
if (key === "e" && !row) return done();
|
|
637
|
+
const locked = (key === "e" ? row : s.settingsRows?.find(row => row.key === s.settingsUndo))?.locked;
|
|
638
|
+
if (locked) { notify(s, locked, now); return done(); }
|
|
599
639
|
const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
|
|
600
640
|
s.settingsEdit = { token, undo: key === "u", ...(key === "e" ? { key: row.key } : {}), phase: key === "u" ? "preview" : "value", text: key === "e" ? settingValueText(row.fileValue) : "" };
|
|
601
641
|
if (key === "u") effects.push({ type: "settings_preview", token, undo: true });
|
|
@@ -666,6 +706,16 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
666
706
|
return done();
|
|
667
707
|
}
|
|
668
708
|
if (s.mode === "panels" && item !== undefined && item !== null) {
|
|
709
|
+
if (s.panel === 3 && (selected.state === "backlog" && ["p", "a", "e"].includes(key) || key === "R" && ["proposed", "in_progress"].includes(selected.state))) {
|
|
710
|
+
const ownerless = key === "R" && selected.state === "proposed" && !selected.owner;
|
|
711
|
+
const revision = ownerless ? selected.flowRevision : selected.planningRevision ?? 0;
|
|
712
|
+
if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "backlog row actions need a valid revision; refresh the board", now); return done(); }
|
|
713
|
+
const sub = key === "R" ? "return" : key === "p" ? "pull-ready" : "plan";
|
|
714
|
+
s.actionLock = { prefix: ["backlog", sub], id: String(item), revision, state: String(selected.state), syntax: ownerless ? "ownerless-return" : "planning", revisionField: ownerless ? "flowRevision" : "planningRevision" };
|
|
715
|
+
const fields = key === "a" ? { reserved: selected.reserved ?? "" } : { priority: selected.priority ?? null, rank: selected.rank ?? null, deps: selected.deps ?? [] };
|
|
716
|
+
s.input = ownerless ? `backlog return ${item}` : `backlog ${sub} ${item} ${revision} ${sub === "plan" ? `'${JSON.stringify(fields)}' ` : ""}`;
|
|
717
|
+
s.editing = true; return done();
|
|
718
|
+
}
|
|
669
719
|
if (s.panel === 1 && key === "m") {
|
|
670
720
|
const peer = String(item);
|
|
671
721
|
const info = s.peers[peer];
|
|
@@ -690,7 +740,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
690
740
|
const revision = selected.flowRevision;
|
|
691
741
|
if (!["proposed", "in_progress", "in_review", "changes_requested", "backlog"].includes(selected.state)) return done();
|
|
692
742
|
if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
|
|
693
|
-
s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state) };
|
|
743
|
+
s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state), ...(selected.state === "backlog" ? { revisionField: "flowRevision" as const } : {}) };
|
|
694
744
|
s.input = `task cancel ${item} --revision ${revision} --reason `; s.editing = true; return done();
|
|
695
745
|
}
|
|
696
746
|
if (s.panel === 3 && (key === "a" || key === "u")) {
|
|
@@ -732,7 +782,7 @@ export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now =
|
|
|
732
782
|
return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
|
|
733
783
|
}
|
|
734
784
|
export const TABLES: Record<number, string[]> = {
|
|
735
|
-
1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
785
|
+
1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
736
786
|
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
737
787
|
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
738
788
|
4: ["ID", "PEER", "STATE", "REV", "AGE"],
|
|
@@ -740,11 +790,14 @@ export const TABLES: Record<number, string[]> = {
|
|
|
740
790
|
};
|
|
741
791
|
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
742
792
|
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
793
|
+
const BACKLOG_COLUMNS = ["ID", "PRI", "RESERVED", "DEPS", "CLASS", "AGE", "TITLE"];
|
|
794
|
+
const waitingDeps = (s: ConsoleState, row: any): number[] => (row.deps ?? []).filter((id: number) => !s.tasks.some(task => task.id === id && task.state === "approved"));
|
|
743
795
|
/** One span per column of a panel row; zero counters and unknown values read `-`. */
|
|
744
796
|
export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
|
|
745
797
|
const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
|
|
746
798
|
if (s.panel === 1) {
|
|
747
799
|
const b = s.budget[row.id];
|
|
800
|
+
const wip = s.taskWip?.[row.id];
|
|
748
801
|
const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
|
|
749
802
|
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
750
803
|
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
@@ -752,11 +805,16 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
|
|
|
752
805
|
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
753
806
|
span(row.pullOnly && definitionOf(row.id)?.attach?.pullOnlyForms?.includes("tui") ? "pull-only" : row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
754
807
|
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
808
|
+
span(wip ? `${wip.wip}/${wip.wipLimit}` : "-", wip && wip.wip >= wip.wipLimit ? "attention" : undefined),
|
|
755
809
|
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
756
810
|
span(b?.windows?.length ? b.windows.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${relative(w.resetsAt, now)}` : ""}`).join(", ") : "-"),
|
|
757
811
|
span(row.servedBy ?? row.requestedModel ?? "-")];
|
|
758
812
|
}
|
|
759
813
|
if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
|
|
814
|
+
if (s.panel === 3 && s.backlogView) {
|
|
815
|
+
const waits = waitingDeps(s, row);
|
|
816
|
+
return [id(`#${row.id}`, "info"), span(`${row.priority ?? "-"}/${row.rank ?? "-"}`), span(row.reserved ?? "-"), span(waits.length ? waits.map(id => `#${id}`).join(",") : "ready", waits.length ? "attention" : "success"), span(row.class ?? "-"), span(typeof row.created === "number" ? duration(now - row.created) : "-", "muted"), span(row.pii ? "[pii]" : row.title)];
|
|
817
|
+
}
|
|
760
818
|
if (s.panel === 3) {
|
|
761
819
|
const last = row.history?.at(-1);
|
|
762
820
|
const at = last?.at ?? row.updated ?? row.created;
|
|
@@ -801,20 +859,41 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
|
|
|
801
859
|
return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
|
|
802
860
|
}));
|
|
803
861
|
}
|
|
862
|
+
/** Keep the status/quota columns visible instead of clipping them behind optional peer metadata. */
|
|
863
|
+
function optionalTable(head: string[], rows: Span[][], columns: number, drop = ["MODEL", "WIP", "PAUSE", "REVIEW", "LINK", "Q", "!", "MODE"]): Span[][] {
|
|
864
|
+
const cap = Math.max(24, Math.floor(columns / 3));
|
|
865
|
+
const widths = head.map((name, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, Bun.stringWidth(flat(row[i]?.text ?? ""))), name.length)));
|
|
866
|
+
const keep = head.map((_, i) => i);
|
|
867
|
+
const total = () => keep.reduce((sum, i) => sum + widths[i]!, 0) + 2 * (keep.length - 1);
|
|
868
|
+
for (const name of drop) {
|
|
869
|
+
if (total() <= columns - 2) break;
|
|
870
|
+
const at = keep.findIndex(i => head[i] === name);
|
|
871
|
+
if (at >= 0) keep.splice(at, 1);
|
|
872
|
+
}
|
|
873
|
+
// Very long peer/state/quota values still fit, with their headers intact.
|
|
874
|
+
while (total() > columns - 2) {
|
|
875
|
+
const largest = keep.reduce((best, i) => widths[i]! - head[i]!.length > widths[best]! - head[best]!.length ? i : best, keep[0]!);
|
|
876
|
+
if (widths[largest]! <= head[largest]!.length) break;
|
|
877
|
+
widths[largest] = widths[largest]! - 1;
|
|
878
|
+
}
|
|
879
|
+
return table(keep.map(i => head[i]!), rows.map(row => keep.map(i => row[i]!)), columns, keep.map(i => widths[i]!));
|
|
880
|
+
}
|
|
804
881
|
function taskHints(row: any): string[] {
|
|
882
|
+
if (row.state === "backlog") return ["p pull", "a reserve", "e plan"];
|
|
805
883
|
if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)) return [];
|
|
806
884
|
const review = row.state === "in_review";
|
|
807
885
|
const hints = [review ? "a reviewer" : "a assign"];
|
|
808
886
|
if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
|
|
809
887
|
if (review) hints.push("r review");
|
|
888
|
+
if (["proposed", "in_progress"].includes(row.state)) hints.push("R return");
|
|
810
889
|
return hints;
|
|
811
890
|
}
|
|
812
891
|
const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
|
|
813
892
|
function panelContext(s: ConsoleState, row: any): string[] {
|
|
893
|
+
if (s.panel === 3) return [...(row ? taskHints(row) : []), ...(row && s.features?.includes("task_cancel") ? ["c cancel"] : []), s.backlogView ? "b open" : "b backlog", "n add"];
|
|
814
894
|
if (!row) return [];
|
|
815
895
|
if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
|
|
816
|
-
if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
|
|
817
|
-
if (s.panel === 3) return [...taskHints(row), ...(s.features?.includes("task_cancel") ? ["c cancel"] : [])];
|
|
896
|
+
if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
|
|
818
897
|
if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
|
|
819
898
|
return [];
|
|
820
899
|
}
|
|
@@ -827,7 +906,14 @@ function hint(s: ConsoleState, columns: number): string {
|
|
|
827
906
|
if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
|
|
828
907
|
const rows = panelRows(s); const row = selectedPanelRow(s);
|
|
829
908
|
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(" ");
|
|
830
|
-
|
|
909
|
+
if (s.panel === 3 && Bun.stringWidth(text) > columns) {
|
|
910
|
+
const compact = [...panelContext(s, row), ...(rows.length ? ["j/k"] : []), "? keys", "q quit"];
|
|
911
|
+
if (Bun.stringWidth(compact.join(" ")) > columns) return compact.filter(key => key !== "j/k").join(" ");
|
|
912
|
+
return compact.join(" ");
|
|
913
|
+
}
|
|
914
|
+
if (s.panel !== 1 || Bun.stringWidth(text) <= columns) return text;
|
|
915
|
+
const compact = text.replace("m permission mode", "m mode").replace("w WIP limit", "w WIP").replace("j/k move", "j/k").replace(": command", ": cmd");
|
|
916
|
+
return Bun.stringWidth(compact) > columns ? compact.replace("Enter view", "Enter") : compact;
|
|
831
917
|
}
|
|
832
918
|
/** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
|
|
833
919
|
function header(s: ConsoleState, columns: number): Span[] {
|
|
@@ -893,6 +979,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
893
979
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
894
980
|
if (typeof p.importantWaitingSince === "number") summary.push(span(` important waiting ${duration(now - p.importantWaitingSince)}`, "attention")); // #572 AC1: a busy ACP peer that cannot be steered
|
|
895
981
|
if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
|
|
982
|
+
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) summary.push(span(` ${flat(p.statusNotice.message)}`, "attention"));
|
|
896
983
|
}
|
|
897
984
|
if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
|
|
898
985
|
if (s.retainedConfig?.length) summary.push(span(`${summary.length ? " | " : ""}config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention"));
|
|
@@ -906,7 +993,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
906
993
|
const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
|
|
907
994
|
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
908
995
|
if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
|
|
909
|
-
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
|
|
996
|
+
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
|
|
910
997
|
fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
|
|
911
998
|
fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
|
|
912
999
|
const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
|
|
@@ -924,10 +1011,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
924
1011
|
const noticeLines = noticeShown && s.petEnabled && petSize(s.petSize, columns, rows) !== "line" ? wrap(`Notice: ${s.notice}`, columns).slice(0, Math.max(0, bodyHeight - 6)).map(text => [span(text, s.noticeTone ?? "failure")]) : [];
|
|
925
1012
|
const height = bodyHeight - noticeLines.length;
|
|
926
1013
|
const lines: Span[][] = [];
|
|
927
|
-
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());
|
|
1014
|
+
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
928
1015
|
else if (s.settingsEdit) {
|
|
929
1016
|
const edit = s.settingsEdit;
|
|
930
|
-
|
|
1017
|
+
const row = s.settingsRows?.find(row => row.key === edit.key);
|
|
1018
|
+
const definition: [string, unknown][] = row ? [["type", row.type], ...(row.min !== undefined || row.max !== undefined ? [["range", `${row.min ?? "-"}..${row.max ?? "-"}`] as [string, unknown]] : []), ["risk", row.risk], ["applies", row.applies]] : [];
|
|
1019
|
+
lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ...definition, ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
|
|
931
1020
|
}
|
|
932
1021
|
else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
|
|
933
1022
|
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
@@ -935,7 +1024,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
935
1024
|
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
936
1025
|
}
|
|
937
1026
|
else {
|
|
938
|
-
const data = panelRows(s); const head = s.panel === 1 && showPermissionModes(s) ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : TABLES[s.panel]; const at = selection(s);
|
|
1027
|
+
const data = panelRows(s); const head = s.panel === 3 && s.backlogView ? BACKLOG_COLUMNS : s.panel === 1 && showPermissionModes(s) ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : TABLES[s.panel]; const at = selection(s);
|
|
939
1028
|
// Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
|
|
940
1029
|
// shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
|
|
941
1030
|
const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
|
|
@@ -944,7 +1033,8 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
944
1033
|
const tail = [...allow, ...deny];
|
|
945
1034
|
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);
|
|
946
1035
|
const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
947
|
-
const
|
|
1036
|
+
const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
|
|
1037
|
+
const rowsOf = head ? s.panel === 1 ? optionalTable(head, cellRows, columns) : s.panel === 3 && s.backlogView ? optionalTable(head, cellRows, columns, ["AGE", "CLASS", "DEPS", "PRI"]) : table(head, cellRows, columns) : undefined;
|
|
948
1038
|
if (s.panel === 6) {
|
|
949
1039
|
const row = selectedPanelRow(s);
|
|
950
1040
|
const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
|
|
@@ -955,7 +1045,9 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
955
1045
|
for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
|
|
956
1046
|
}
|
|
957
1047
|
}
|
|
958
|
-
if (s.panel === 3) {
|
|
1048
|
+
if (s.panel === 3 && s.backlogView) {
|
|
1049
|
+
lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
|
|
1050
|
+
} else if (s.panel === 3) {
|
|
959
1051
|
const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
|
|
960
1052
|
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}%`;
|
|
961
1053
|
const known = s.tasksKnown !== false || progress.total > 0;
|
package/src/cli/console.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ConsoleOptions {
|
|
|
31
31
|
/** Explicit delegation inherited only by this console's ahub subprocesses. */
|
|
32
32
|
liveTest?: boolean;
|
|
33
33
|
/** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
|
|
34
|
-
runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
|
|
34
|
+
runCommand?: (args: string[], output: (text: string) => void, done: (code?: number) => void, columns?: number) => (() => void);
|
|
35
35
|
pollMs?: number;
|
|
36
36
|
}
|
|
37
37
|
/** Exact navigation sequences only (#309, #441); anything else in a multi-character read is a paste. */
|
|
@@ -190,7 +190,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
190
190
|
const replies = await Promise.all([
|
|
191
191
|
client.request({ t: "status" }, 3000), client.request({ t: "budget" }, 3000),
|
|
192
192
|
...(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)] : []),
|
|
193
|
-
...(panels && state.panel === 6 ? [client.request({ t: "settings_get" }, 3000)] : []),
|
|
193
|
+
...(panels && (state.panel === 1 || state.panel === 6) ? [client.request({ t: "settings_get" }, 3000)] : []),
|
|
194
194
|
]);
|
|
195
195
|
if (!active) return;
|
|
196
196
|
const [status, budget, tasks, queue, ready, settings] = replies;
|
|
@@ -210,6 +210,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
210
210
|
if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
|
|
211
211
|
if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
|
|
212
212
|
if (status.status && status.ok !== false) {
|
|
213
|
+
state.taskWip = status.status.taskWip ?? {};
|
|
213
214
|
state.attentionFacts = attentionFacts(status.status.attentionItems);
|
|
214
215
|
state.storageBlocked = !!status.status.deliveryError;
|
|
215
216
|
state.recoveryNeed = status.status.recovery ? { id: status.status.recovery.operationId, running: true } : undefined;
|
|
@@ -249,7 +250,8 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
249
250
|
try { while (true) { const { value, done } = await reader.read(); if (done) break; output(decoder.decode(value, { stream: true })); } const end = decoder.decode(); if (end) output(end); }
|
|
250
251
|
finally { reader.releaseLock(); }
|
|
251
252
|
};
|
|
252
|
-
|
|
253
|
+
let exitCode = 0;
|
|
254
|
+
void Promise.all([read(child.stdout), read(child.stderr), child.exited]).then(([, , code]) => { exitCode = code; if (code) output(`command exited ${code}`); }).catch(error => { exitCode = 1; output(String(error.message)); }).finally(() => finished(exitCode));
|
|
253
255
|
return () => { child.kill(); };
|
|
254
256
|
});
|
|
255
257
|
const effect = async (action: ConsoleEffect) => {
|
|
@@ -257,7 +259,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
257
259
|
if (action.type === "settings_preview") {
|
|
258
260
|
const response = await client.request(action.undo ? { t: "settings_preview", undo: true } : { t: "settings_preview", key: action.key, value: action.value }, 3000);
|
|
259
261
|
const edit = state.settingsEdit;
|
|
260
|
-
if (!active || state.mode !== "panels" || state.panel !==
|
|
262
|
+
if (!active || state.mode !== "panels" || !edit || state.panel !== (edit.panel ?? 6) || 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;
|
|
261
263
|
if (response.ok === false) { state.settingsEdit = undefined; notice(String(response.error ?? "setting preview refused")); }
|
|
262
264
|
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))) {
|
|
263
265
|
state.settingsEdit = undefined; notice("undo preview is unavailable or changed; reload settings and review it again");
|
|
@@ -299,9 +301,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
299
301
|
childRunning = true;
|
|
300
302
|
stream({ text: `> ${action.args.join(" ")}`, kind: "command" });
|
|
301
303
|
try {
|
|
304
|
+
let diagnostic = "";
|
|
302
305
|
// Output lines sit at column 4, as message bodies do: what a command prints can carry peer text, and columns 0 and 2 are the hub's.
|
|
303
|
-
const output = (text: string) => { if (active) { stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
|
|
304
|
-
cancelChild = run(action.args, output, () => { childRunning = false; cancelChild = undefined; if (active) void refresh(); }, Math.max(1, columns - 4));
|
|
306
|
+
const output = (text: string) => { if (active) { diagnostic = (diagnostic + terminalText(text)).slice(-2000); stream({ text: terminalText(text).replace(/\t/g, " ").replace(/\n$/, "").replace(/^/gm, " "), kind: "command" }); draw(); } };
|
|
307
|
+
cancelChild = run(action.args, output, (code = 0) => { childRunning = false; cancelChild = undefined; if (active) { if (code) notice(diagnostic.trim() || `command exited ${code}`); void refresh(); } }, Math.max(1, columns - 4));
|
|
305
308
|
} catch (error) { childRunning = false; notice(String((error as Error).message)); draw(); }
|
|
306
309
|
};
|
|
307
310
|
const handle = (key: string) => {
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
2
3
|
import { definitionOf } from "../adapters/registry.ts";
|
|
3
4
|
// Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
|
|
4
5
|
// PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
|
|
@@ -101,7 +102,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
101
102
|
if (import.meta.main) {
|
|
102
103
|
try {
|
|
103
104
|
const stateDir = process.env.AGENTHUB_STATE_DIR;
|
|
104
|
-
const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ??
|
|
105
|
+
const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined), 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
|
|
105
106
|
if (out) console.log(out);
|
|
106
107
|
} catch {
|
|
107
108
|
// a hook that fails must not get in the way of the tool call
|
package/src/cli/help.ts
CHANGED
|
@@ -14,6 +14,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
14
14
|
["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
|
|
15
15
|
["ahub help [command]", "this help, or one command's entries; also --help and -h"],
|
|
16
16
|
["ahub version | --version", "print the installed version"],
|
|
17
|
+
["ahub completion zsh|bash|fish", "print a shell completion script; its candidates come from these help entries"],
|
|
17
18
|
]],
|
|
18
19
|
["Daemon and runtime", [
|
|
19
20
|
["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
|
|
@@ -47,6 +48,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
47
48
|
["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
|
|
48
49
|
]],
|
|
49
50
|
["Agents", [
|
|
51
|
+
["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
|
|
50
52
|
["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
|
|
51
53
|
["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
|
|
52
54
|
["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
|
|
@@ -88,7 +90,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
88
90
|
["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
|
|
89
91
|
["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
|
|
90
92
|
["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
|
|
91
|
-
["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"],
|
|
93
|
+
["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; a peer that cannot take it is refused with the reason; --revision refuses stale console actions"],
|
|
92
94
|
["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"],
|
|
93
95
|
["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"],
|
|
94
96
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
@@ -177,3 +179,8 @@ export function renderHelp(columns: number, color: boolean, command?: string): s
|
|
|
177
179
|
}
|
|
178
180
|
return lines.map(line => paint(line, color)).join("\n");
|
|
179
181
|
}
|
|
182
|
+
|
|
183
|
+
/** Body-free configured command guidance; no native argv or environment values enter help. */
|
|
184
|
+
export function configuredAcpHelp(kind: string): string {
|
|
185
|
+
return `ahub ${kind} [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]\nConfigured ACP runs headless through the hub, with native permissions and NO hub sandbox.\nDeclare it in untracked .agenthub/config.local.json, restart the hub, then enable roster.${kind}.\nPreview withholds native argv and starts nothing. Native/model overrides and --unattended are refused.`;
|
|
186
|
+
}
|
package/src/cli/identity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { nativeCliMarkerPeer } from "../adapters/definitions/native-identity-defaults.ts";
|
|
1
2
|
import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
|
|
2
|
-
import { definitionOf, kindOf, type KindMap } from "../adapters/registry.ts";
|
|
3
|
+
import { definitionOf, kindOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
4
|
|
|
4
5
|
/** Recovery reads this declaration before delegating to another package's CLI. */
|
|
5
6
|
export const LIVE_TEST_SUPPORTED = true;
|
|
@@ -42,7 +43,7 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
|
|
|
42
43
|
if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
|
|
43
44
|
if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
|
|
44
45
|
if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
|
|
45
|
-
const native = claude !== undefined
|
|
46
|
+
const native = nativeCliMarkerPeer(claude !== undefined, codex !== undefined);
|
|
46
47
|
if (hub !== undefined && native !== undefined && (kindOf(hub, instances) ?? hub) !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
|
|
47
48
|
const peer = hub ?? native;
|
|
48
49
|
return peer === undefined ? { role: "console" } : { role: "tools", peer };
|
|
@@ -51,9 +52,9 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
|
|
|
51
52
|
export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
52
53
|
|
|
53
54
|
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
54
|
-
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
55
|
+
export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
|
|
55
56
|
if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
|
|
56
|
-
const native = definitionOf(command);
|
|
57
|
+
const native = catalog?.definitionOf(command) ?? definitionOf(command);
|
|
57
58
|
if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
|
|
58
59
|
if (command === "pet" && args.includes("--window")) return "console";
|
|
59
60
|
if (command === "permission" || command === "stop" || command === "watch") return "console";
|
|
@@ -77,16 +78,16 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
|
|
|
77
78
|
if (native?.cli.authority === "person") return "console";
|
|
78
79
|
if (native || ["pause", "resume"].includes(command)) return "conductor";
|
|
79
80
|
if (command === "board" && args[0] === "import") return "console";
|
|
80
|
-
if (["help", "version", "--version", "say", "status", "pet", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
|
|
81
|
+
if (["help", "version", "--version", "completion", "say", "status", "pet", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
|
|
81
82
|
return "console";
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/** No argument text is included: notices and refusal logs must not copy task text or credentials. */
|
|
85
|
-
export function cliCommandLabel(command: string, args: readonly string[]): string {
|
|
86
|
+
export function cliCommandLabel(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): string {
|
|
86
87
|
if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
|
|
87
88
|
if (command === "board" && args[0] === "import") return "board import";
|
|
88
89
|
if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
|
|
89
|
-
if (!definitionOf(command) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "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";
|
|
90
|
+
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "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";
|
|
90
91
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
|
|
91
92
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
92
93
|
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}`;
|