@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +5 -4
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +11 -4
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/bus.md +2 -0
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/tasks.md +7 -2
  10. package/docs/agent-notes/tests.md +6 -2
  11. package/docs/operations.md +159 -26
  12. package/docs/quickstart.md +11 -3
  13. package/docs/sdlc.md +1 -1
  14. package/docs/security.md +7 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
  16. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  19. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  20. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  21. package/docs/verified.json +34 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +808 -255
  25. package/src/adapters/acp-owned-group.ts +124 -0
  26. package/src/adapters/acp.ts +92 -25
  27. package/src/adapters/agy/definition.ts +1 -3
  28. package/src/adapters/agy/entry.ts +4 -1
  29. package/src/adapters/agy/hooks.ts +7 -2
  30. package/src/adapters/channel-peer.ts +143 -0
  31. package/src/adapters/claude-channel.ts +7 -7
  32. package/src/adapters/codex-appserver.ts +16 -2
  33. package/src/adapters/configured-acp.ts +124 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  35. package/src/adapters/definitions/native-channel-state.ts +41 -0
  36. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  37. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  38. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  39. package/src/adapters/definitions/qwen.ts +3 -8
  40. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  41. package/src/adapters/jcode.ts +4 -0
  42. package/src/adapters/local-worker.ts +4 -0
  43. package/src/adapters/pi.ts +50 -16
  44. package/src/adapters/registry-types.ts +18 -16
  45. package/src/adapters/registry.ts +68 -6
  46. package/src/adapters/reviewer-print.ts +4 -0
  47. package/src/cli/agy-mcp.ts +3 -2
  48. package/src/cli/completion.ts +173 -0
  49. package/src/cli/configured-acp-entry.ts +58 -0
  50. package/src/cli/console-state.ts +116 -24
  51. package/src/cli/console.ts +9 -6
  52. package/src/cli/facts-hook.ts +2 -1
  53. package/src/cli/help.ts +8 -1
  54. package/src/cli/identity.ts +8 -7
  55. package/src/cli/launch.ts +5 -4
  56. package/src/cli/main.js +5 -1
  57. package/src/cli/main.ts +84 -27
  58. package/src/cli/memory.ts +13 -10
  59. package/src/cli/output.ts +1 -0
  60. package/src/cli/preview.ts +9 -0
  61. package/src/cli/recovery-guidance.ts +1 -1
  62. package/src/cli/terminal-recovery.ts +33 -21
  63. package/src/cli/upgrade-restore-state.ts +3 -2
  64. package/src/cli/upgrade-runtime.ts +21 -6
  65. package/src/cli/upgrade.ts +3 -2
  66. package/src/hub/board.ts +9 -6
  67. package/src/hub/budget.ts +9 -2
  68. package/src/hub/bus.ts +6 -2
  69. package/src/hub/claude-state.ts +2 -37
  70. package/src/hub/conductor.ts +8 -6
  71. package/src/hub/config-apply.ts +26 -14
  72. package/src/hub/config-reload.ts +39 -11
  73. package/src/hub/config-trust.ts +1 -1
  74. package/src/hub/context-window.ts +6 -0
  75. package/src/hub/control-client.ts +1 -1
  76. package/src/hub/crash.ts +8 -6
  77. package/src/hub/daemon.ts +471 -338
  78. package/src/hub/hub-tools.ts +37 -1
  79. package/src/hub/instance-config.ts +20 -11
  80. package/src/hub/instance-execution.ts +4 -4
  81. package/src/hub/instances.ts +45 -21
  82. package/src/hub/lifecycle-schema.ts +5 -3
  83. package/src/hub/main-checkout.ts +2 -2
  84. package/src/hub/manager.ts +10 -2
  85. package/src/hub/memory-profile.ts +16 -3
  86. package/src/hub/no-writes.ts +50 -0
  87. package/src/hub/permission-mode.ts +9 -7
  88. package/src/hub/roster.ts +7 -4
  89. package/src/hub/routing.ts +21 -20
  90. package/src/hub/settings.ts +51 -17
  91. package/src/hub/snapshots.ts +276 -17
  92. package/src/hub/start-mode.ts +10 -8
  93. package/src/hub/task-sweep.ts +8 -2
  94. package/src/hub/tasks.ts +101 -19
  95. package/src/memory/capture.ts +2 -1
  96. package/src/ui/index.html +5 -0
  97. package/templates/AGENTS.block.md +1 -1
  98. package/templates/profiles/general.json +1 -1
  99. package/templates/profiles/research.json +1 -1
  100. 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
+ }
@@ -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/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
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
- if (args[0] !== lock.prefix[0] || args[1] !== lock.prefix[1] || args[2] !== lock.id || revisions.length !== 1 || revisions[0] !== String(lock.revision)) {
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
- 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;
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
- lines.push(...fieldLines([["setting", edit.key ?? s.settingsUndo ?? "last write"], ["preview", (edit.lines ?? []).join("\n")], ["decision", "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
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 rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now, stages)), columns) : undefined;
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;
@@ -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
- void Promise.all([read(child.stdout), read(child.stderr), child.exited]).then(([, , code]) => { if (code) output(`command exited ${code}`); }).catch(error => output(String(error.message))).finally(finished);
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 !== 6 || !edit || edit.phase !== "preview" || edit.token !== action.token || edit.undo !== action.undo || edit.key !== action.key || (!action.undo && JSON.stringify(edit.value) !== JSON.stringify(action.value))) return;
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) => {
@@ -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 ?? "claude", 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
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
+ }
@@ -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 ? "claude" : codex !== undefined ? "codex" : 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}`;