@staix/agent-hub 0.12.42 → 0.12.43

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.
@@ -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[];
@@ -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" : ""}`;
@@ -493,6 +507,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
493
507
  return done();
494
508
  }
495
509
  if (key === "y") {
510
+ if (confirm.type === "command" && confirm.lock) {
511
+ try { validateLockedRow(s, confirm.lock); } catch (error) { notify(s, (error as Error).message, now); return done(); }
512
+ }
496
513
  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
514
  else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
498
515
  effects.push({ type: "permit", id: confirm.id, option: confirm.option });
@@ -535,15 +552,23 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
535
552
  }
536
553
  if (lock) {
537
554
  validateLockedCommand(lock, args);
555
+ validateLockedRow(s, lock);
556
+ if (lock.syntax === "planning" && args.length < (lock.prefix[1] === "plan" ? 6 : 5)) throw new Error("backlog row actions require a reason");
538
557
  if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
539
558
  }
540
559
  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) } : {}) };
560
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args), ...(lock.revisionField ? { lock } : {}) } : {}) };
542
561
  else effects.push({ type: "command", args });
543
562
  } catch (error) { notify(s, String((error as Error).message), now); }
544
563
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
545
564
  return done();
546
565
  }
566
+ if (s.mode === "panels" && s.panel === 3 && key === "b") {
567
+ s.backlogView = !s.backlogView; s.selection = 0; s.taskSelection = undefined; s.detail = undefined; s.detailOffset = 0; return done();
568
+ }
569
+ if (s.mode === "panels" && s.panel === 3 && key === "n" && s.detail === undefined) {
570
+ s.input = "backlog add implement "; s.editing = true; return done();
571
+ }
547
572
  if ((key === "n" || key === "P") && attentionKeyBlocked) return done();
548
573
  if (key === "q") { effects.push({ type: "exit" }); return done(); }
549
574
  if (key === "P" && !s.optionChoice && !s.help) {
@@ -572,9 +597,9 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
572
597
  const selected = item.id === undefined ? -1 : rows.findIndex(row => row.id === item.id);
573
598
  if (selected >= 0) { s.panel = 4; s.selection = selected; navigated = true; }
574
599
  } else if (item.kind === "review" && s.mode === "panels") {
575
- const rows = panelRows({ ...s, panel: 3 });
600
+ const rows = panelRows({ ...s, panel: 3, backlogView: false });
576
601
  const selected = rows.findIndex(row => row.id === item.id);
577
- if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
602
+ if (selected >= 0) { s.panel = 3; s.backlogView = false; s.selection = selected; navigated = true; }
578
603
  }
579
604
  if (navigated) { captureConsoleSelection(s, true); s.detail = undefined; s.requestDetail = undefined; }
580
605
  else {
@@ -592,6 +617,17 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
592
617
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
593
618
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
594
619
  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);
620
+ if (s.mode === "panels" && s.panel === 1 && key === "w") {
621
+ const peer = selectedPanelRow(s)?.id;
622
+ if (!peer || s.detail !== undefined) { notify(s, "select a peer row to edit its planner WIP limit", now); return done(); }
623
+ const wip = s.taskWip?.[peer];
624
+ if (!wip || s.peers[peer]?.rostered === false) { notify(s, `planner WIP is unavailable for ${peer}`, now); return done(); }
625
+ const row = s.settingsRows?.find(row => row.key === `tasks.wip_limits.${peer}`);
626
+ if (!row || s.settingsProblem) { notify(s, s.settingsProblem ?? "WIP setting is unavailable; refresh settings and try again", now); return done(); }
627
+ const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
628
+ s.settingsEdit = { token, panel: 1, key: row.key, undo: false, phase: "value", text: String(wip.wipLimit) };
629
+ return done();
630
+ }
595
631
  if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
596
632
  const row = selectedPanelRow(s);
597
633
  if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
@@ -666,6 +702,16 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
666
702
  return done();
667
703
  }
668
704
  if (s.mode === "panels" && item !== undefined && item !== null) {
705
+ if (s.panel === 3 && (selected.state === "backlog" && ["p", "a", "e"].includes(key) || key === "R" && ["proposed", "in_progress"].includes(selected.state))) {
706
+ const ownerless = key === "R" && selected.state === "proposed" && !selected.owner;
707
+ const revision = ownerless ? selected.flowRevision : selected.planningRevision ?? 0;
708
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "backlog row actions need a valid revision; refresh the board", now); return done(); }
709
+ const sub = key === "R" ? "return" : key === "p" ? "pull-ready" : "plan";
710
+ s.actionLock = { prefix: ["backlog", sub], id: String(item), revision, state: String(selected.state), syntax: ownerless ? "ownerless-return" : "planning", revisionField: ownerless ? "flowRevision" : "planningRevision" };
711
+ const fields = key === "a" ? { reserved: selected.reserved ?? "" } : { priority: selected.priority ?? null, rank: selected.rank ?? null, deps: selected.deps ?? [] };
712
+ s.input = ownerless ? `backlog return ${item}` : `backlog ${sub} ${item} ${revision} ${sub === "plan" ? `'${JSON.stringify(fields)}' ` : ""}`;
713
+ s.editing = true; return done();
714
+ }
669
715
  if (s.panel === 1 && key === "m") {
670
716
  const peer = String(item);
671
717
  const info = s.peers[peer];
@@ -690,7 +736,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
690
736
  const revision = selected.flowRevision;
691
737
  if (!["proposed", "in_progress", "in_review", "changes_requested", "backlog"].includes(selected.state)) return done();
692
738
  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) };
739
+ s.actionLock = { prefix: ["task", "cancel"], id: String(item), revision, state: String(selected.state), ...(selected.state === "backlog" ? { revisionField: "flowRevision" as const } : {}) };
694
740
  s.input = `task cancel ${item} --revision ${revision} --reason `; s.editing = true; return done();
695
741
  }
696
742
  if (s.panel === 3 && (key === "a" || key === "u")) {
@@ -732,7 +778,7 @@ export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now =
732
778
  return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
733
779
  }
734
780
  export const TABLES: Record<number, string[]> = {
735
- 1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
781
+ 1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
736
782
  2: ["ID", "PEER", "LEFT", "TITLE"],
737
783
  3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
738
784
  4: ["ID", "PEER", "STATE", "REV", "AGE"],
@@ -740,11 +786,14 @@ export const TABLES: Record<number, string[]> = {
740
786
  };
741
787
  const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
742
788
  const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
789
+ const BACKLOG_COLUMNS = ["ID", "PRI", "RESERVED", "DEPS", "CLASS", "AGE", "TITLE"];
790
+ const waitingDeps = (s: ConsoleState, row: any): number[] => (row.deps ?? []).filter((id: number) => !s.tasks.some(task => task.id === id && task.state === "approved"));
743
791
  /** One span per column of a panel row; zero counters and unknown values read `-`. */
744
792
  export function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
745
793
  const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
746
794
  if (s.panel === 1) {
747
795
  const b = s.budget[row.id];
796
+ const wip = s.taskWip?.[row.id];
748
797
  const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
749
798
  ? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
750
799
  : row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
@@ -752,11 +801,16 @@ export function cells(s: ConsoleState, row: any, selected: boolean, now: number,
752
801
  ...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
753
802
  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
803
  span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
804
+ span(wip ? `${wip.wip}/${wip.wipLimit}` : "-", wip && wip.wip >= wip.wipLimit ? "attention" : undefined),
755
805
  span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
756
806
  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
807
  span(row.servedBy ?? row.requestedModel ?? "-")];
758
808
  }
759
809
  if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
810
+ if (s.panel === 3 && s.backlogView) {
811
+ const waits = waitingDeps(s, row);
812
+ 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)];
813
+ }
760
814
  if (s.panel === 3) {
761
815
  const last = row.history?.at(-1);
762
816
  const at = last?.at ?? row.updated ?? row.created;
@@ -801,20 +855,41 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
801
855
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
802
856
  }));
803
857
  }
858
+ /** Keep the status/quota columns visible instead of clipping them behind optional peer metadata. */
859
+ function optionalTable(head: string[], rows: Span[][], columns: number, drop = ["MODEL", "WIP", "PAUSE", "REVIEW", "LINK", "Q", "!", "MODE"]): Span[][] {
860
+ const cap = Math.max(24, Math.floor(columns / 3));
861
+ const widths = head.map((name, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, Bun.stringWidth(flat(row[i]?.text ?? ""))), name.length)));
862
+ const keep = head.map((_, i) => i);
863
+ const total = () => keep.reduce((sum, i) => sum + widths[i]!, 0) + 2 * (keep.length - 1);
864
+ for (const name of drop) {
865
+ if (total() <= columns - 2) break;
866
+ const at = keep.findIndex(i => head[i] === name);
867
+ if (at >= 0) keep.splice(at, 1);
868
+ }
869
+ // Very long peer/state/quota values still fit, with their headers intact.
870
+ while (total() > columns - 2) {
871
+ const largest = keep.reduce((best, i) => widths[i]! - head[i]!.length > widths[best]! - head[best]!.length ? i : best, keep[0]!);
872
+ if (widths[largest]! <= head[largest]!.length) break;
873
+ widths[largest] = widths[largest]! - 1;
874
+ }
875
+ return table(keep.map(i => head[i]!), rows.map(row => keep.map(i => row[i]!)), columns, keep.map(i => widths[i]!));
876
+ }
804
877
  function taskHints(row: any): string[] {
878
+ if (row.state === "backlog") return ["p pull", "a reserve", "e plan"];
805
879
  if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)) return [];
806
880
  const review = row.state === "in_review";
807
881
  const hints = [review ? "a reviewer" : "a assign"];
808
882
  if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
809
883
  if (review) hints.push("r review");
884
+ if (["proposed", "in_progress"].includes(row.state)) hints.push("R return");
810
885
  return hints;
811
886
  }
812
887
  const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
813
888
  function panelContext(s: ConsoleState, row: any): string[] {
889
+ 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
890
  if (!row) return [];
815
891
  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"] : [])];
892
+ if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
818
893
  if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
819
894
  return [];
820
895
  }
@@ -827,7 +902,14 @@ function hint(s: ConsoleState, columns: number): string {
827
902
  if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
828
903
  const rows = panelRows(s); const row = selectedPanelRow(s);
829
904
  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;
905
+ if (s.panel === 3 && Bun.stringWidth(text) > columns) {
906
+ const compact = [...panelContext(s, row), ...(rows.length ? ["j/k"] : []), "? keys", "q quit"];
907
+ if (Bun.stringWidth(compact.join(" ")) > columns) return compact.filter(key => key !== "j/k").join(" ");
908
+ return compact.join(" ");
909
+ }
910
+ if (s.panel !== 1 || Bun.stringWidth(text) <= columns) return text;
911
+ const compact = text.replace("m permission mode", "m mode").replace("w WIP limit", "w WIP").replace("j/k move", "j/k").replace(": command", ": cmd");
912
+ return Bun.stringWidth(compact) > columns ? compact.replace("Enter view", "Enter") : compact;
831
913
  }
832
914
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
833
915
  function header(s: ConsoleState, columns: number): Span[] {
@@ -927,7 +1009,9 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
927
1009
  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());
928
1010
  else if (s.settingsEdit) {
929
1011
  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());
1012
+ const row = s.settingsRows?.find(row => row.key === edit.key);
1013
+ 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]] : [];
1014
+ 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
1015
  }
932
1016
  else if (s.help) lines.push(...keyTable(columns, !!s.petEnabled || !!s.petChoices?.length, !!s.petChoices?.length));
933
1017
  else if (s.detail !== undefined || (s.requestDetail && permission)) {
@@ -935,7 +1019,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
935
1019
  lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
936
1020
  }
937
1021
  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);
1022
+ 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
1023
  // Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
940
1024
  // shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
941
1025
  const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
@@ -944,7 +1028,8 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
944
1028
  const tail = [...allow, ...deny];
945
1029
  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
1030
  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;
1031
+ const cellRows = head ? data.map((row, i) => cells(s, row, i === at, now, stages)) : [];
1032
+ 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
1033
  if (s.panel === 6) {
949
1034
  const row = selectedPanelRow(s);
950
1035
  const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
@@ -955,7 +1040,9 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
955
1040
  for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
956
1041
  }
957
1042
  }
958
- if (s.panel === 3) {
1043
+ if (s.panel === 3 && s.backlogView) {
1044
+ lines.push(fitLine([span(s.tasksKnown === false ? "backlog loading..." : `backlog ${data.length}`, s.tasksKnown === false ? "attention" : "info")], columns));
1045
+ } else if (s.panel === 3) {
959
1046
  const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
960
1047
  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
1048
  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) => {
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"],
@@ -88,7 +89,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
88
89
  ["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
90
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
90
91
  ["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"],
92
+ ["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
93
  ["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
94
  ["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
95
  ["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)"],
@@ -77,7 +77,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
77
77
  if (native?.cli.authority === "person") return "console";
78
78
  if (native || ["pause", "resume"].includes(command)) return "conductor";
79
79
  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";
80
+ if (["help", "version", "--version", "completion", "say", "status", "pet", "board", "report", "turns", "review", "remember", "facts", "check-path"].includes(command)) return "allowed";
81
81
  return "console";
82
82
  }
83
83
 
@@ -86,7 +86,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
86
86
  if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
87
87
  if (command === "board" && args[0] === "import") return "board import";
88
88
  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";
89
+ if (!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
90
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
91
91
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
92
92
  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}`;
package/src/cli/main.js CHANGED
@@ -6,4 +6,8 @@ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && (Number
6
6
  process.exit(1);
7
7
  }
8
8
 
9
- await import("./main.ts");
9
+ // Shell completion asks on every Tab: answer from the help entries without loading the CLI (#603).
10
+ if (process.argv[2] === "__complete") {
11
+ const { completeWords } = await import("./completion.ts");
12
+ console.log(completeWords(process.argv.slice(process.argv[3] === "--" ? 4 : 3)).join("\n"));
13
+ } else await import("./main.ts");
package/src/cli/main.ts CHANGED
@@ -73,6 +73,7 @@ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, label
73
73
  import { summarize, summarizeByTask } from "../hub/report.ts";
74
74
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
75
75
  import { pathWarnings } from "../hub/conflicts.ts";
76
+ import { COMPLETION_SHELLS, completionScript } from "./completion.ts";
76
77
  import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
77
78
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
78
79
  import { runConsole } from "./console.ts";
@@ -765,6 +766,11 @@ const commands: Record<string, () => Promise<void> | void> = {
765
766
  if (!text) fail(`unknown command "${topic}"; run ahub help`);
766
767
  console.log(text);
767
768
  },
769
+ completion: () => {
770
+ const script = completionScript(args[0]);
771
+ if (args.length !== 1 || !script) fail(`usage: ahub completion ${COMPLETION_SHELLS.join("|")}`);
772
+ process.stdout.write(script!);
773
+ },
768
774
  "--version": () => console.log(VERSION),
769
775
  version: () => console.log(VERSION),
770
776
 
@@ -179,6 +179,8 @@ const MAX_WAIT_MS = 10 * 60 * 1000;
179
179
  export const MAX_COMMAND_MS = 30_000;
180
180
  /** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
181
181
  const EXIT_SLICE_MS = 5_000;
182
+ /** #599: after the replacement reads idle, its session mapping is re-read this often until the deadline. */
183
+ const MAP_POLL_MS = 1_000;
182
184
  const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
183
185
  type AllowedEnvName = (typeof ALLOWED_ENV)[number];
184
186
 
@@ -909,16 +911,23 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
909
911
  idle = await waitForIdle(replacement, slice(), config);
910
912
  }
911
913
  if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
912
- // An idle terminal may be the login shell the launcher returned to.
913
- const launch = await launches();
914
- if (launch.gone) return exited("exited");
915
- const shown = await showBinding(replacement, config);
916
- const identity = identityFrom(shown);
917
- const session = sessionFrom(shown);
918
- const recorded = launch.live;
919
914
  const same = (value: string | undefined) => anySession || value === binding.sessionId;
920
- if ((identity !== binding.peer || !same(session)) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) {
921
- return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
915
+ let session: string | undefined;
916
+ // An idle terminal may be the login shell the launcher returned to, or a TUI that has not yet recorded its
917
+ // launch or been identified by Orca (#599): poll until it maps, contradicts, or the deadline passes.
918
+ for (;;) {
919
+ const launch = await launches();
920
+ if (launch.gone) return exited("exited");
921
+ const shown = await showBinding(replacement, config);
922
+ const identity = identityFrom(shown);
923
+ session = sessionFrom(shown);
924
+ const recorded = launch.live;
925
+ if ((identity === binding.peer && same(session)) || (recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) break;
926
+ const contradicts = (identity !== undefined && identity !== binding.peer) || (session !== undefined && !same(session));
927
+ if (contradicts || Date.now() >= deadline) {
928
+ return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
929
+ }
930
+ await Bun.sleep(Math.max(1, Math.min(MAP_POLL_MS, deadline - Date.now())));
922
931
  }
923
932
  if (anySession && session) replacement = { ...replacement, sessionId: session };
924
933
  return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };