@staix/agent-hub 0.12.21 → 0.12.23

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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
@@ -0,0 +1,48 @@
1
+ /**
2
+ * #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
3
+ * `ahub <peer>` gets the same default and can override it with a flag.
4
+ */
5
+ /** Shared terminal-start guard and dashboard retry hold after an unconfirmed response. */
6
+ export const PEER_START_RETRY_MS = 30_000;
7
+ export const START_MODES = ["tui", "headless"] as const;
8
+ export type StartMode = typeof START_MODES[number];
9
+ /** The peers with both forms. Claude has only a TUI; Kimi (ACP) and the local worker have none the hub attaches to. */
10
+ export const START_MODE_PEERS = ["pi", "codex"] as const;
11
+ /** Every peer the hub can start itself. */
12
+ export const STARTABLE_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
13
+ export type StartablePeer = typeof STARTABLE_PEERS[number];
14
+ export const isStartablePeer = (value: unknown): value is StartablePeer => typeof value === "string" && (STARTABLE_PEERS as readonly string[]).includes(value);
15
+
16
+ export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
17
+ /** The checked `peers` block of the config; throws on anything but `{ pi|codex: { start_mode: "tui"|"headless" } }`. */
18
+ export function peerStartConfig(value: unknown): PeerStartConfig {
19
+ if (value === undefined) return {};
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
21
+ const result: PeerStartConfig = {};
22
+ for (const [peer, block] of Object.entries(value)) {
23
+ if (!(START_MODE_PEERS as readonly string[]).includes(peer)) throw new Error(`peers.${peer}: only ${START_MODE_PEERS.join(" and ")} have a start mode`);
24
+ if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
25
+ const mode = (block as { start_mode?: unknown }).start_mode;
26
+ if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
27
+ result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
28
+ }
29
+ return result;
30
+ }
31
+
32
+ /** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
33
+ export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
34
+ if (peer === "claude") return "tui";
35
+ if ((START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? "tui";
36
+ return "headless";
37
+ }
38
+
39
+ /**
40
+ * The checked `terminal.open` template: an argv in which exactly one element contains `{command}` (replaced by the
41
+ * shell-quoted command the hub assembled); `{title}` and `{cwd}` are replaced too. Empty means no template.
42
+ */
43
+ export function terminalTemplate(value: unknown): string[] {
44
+ if (value === undefined || value === null) return [];
45
+ if (!Array.isArray(value) || value.some((part) => typeof part !== "string" || !part)) throw new Error("terminal.open must be a list of non-empty strings (an argv with a {command} placeholder)");
46
+ if (value.length && value.filter((part: string) => part.includes("{command}")).length !== 1) throw new Error("terminal.open needs {command} in exactly one element");
47
+ return [...value];
48
+ }
package/src/hub/tasks.ts CHANGED
@@ -92,6 +92,24 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
92
92
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
93
93
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
94
94
 
95
+ /**
96
+ * Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
97
+ * fact, the delivery is unconfirmed. Callers decide by this type, never by comparing owners or reading history.
98
+ * It stays a plain Error subclass, so the preEffect rule and the #231 ready-offer contract are unchanged.
99
+ * `task` and `cause` are non-enumerable: a PII task's title and detail must not reach a JSON.stringify of the error.
100
+ */
101
+ export class AssignmentUndeliveredError extends Error {
102
+ /** The task as the board has it after the write. */
103
+ declare readonly task: Task;
104
+ declare readonly cause: unknown;
105
+ constructor(task: Task, cause: unknown) {
106
+ super(`task #${task.id}: the assignment to ${task.owner} is saved, but its delivery is not confirmed: ${cause instanceof Error ? cause.message : String(cause)}`);
107
+ this.name = "AssignmentUndeliveredError";
108
+ Object.defineProperty(this, "task", { value: task, enumerable: false });
109
+ Object.defineProperty(this, "cause", { value: cause, enumerable: false });
110
+ }
111
+ }
112
+
95
113
  /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
96
114
  export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
97
115
 
@@ -242,7 +260,13 @@ export class Tasks {
242
260
  this.passedOver(task, a);
243
261
  this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
244
262
  if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
245
- await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
263
+ try {
264
+ await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
265
+ } catch (e) {
266
+ // A saved move whose delivery failed: the console notice was printed where the error was thrown, and
267
+ // the sweep goes on (#297).
268
+ if (!(e instanceof AssignmentUndeliveredError)) throw e;
269
+ }
246
270
  }
247
271
  }
248
272
  }
@@ -569,7 +593,12 @@ export class Tasks {
569
593
  this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
570
594
  const still = this.waitsFor(lifted);
571
595
  if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
572
- await this.assignOwner(lifted, HUB);
596
+ try {
597
+ await this.assignOwner(lifted, HUB);
598
+ } catch (e) {
599
+ // A saved move whose delivery failed: the console notice was printed where the error was thrown (#297).
600
+ if (!(e instanceof AssignmentUndeliveredError)) throw e;
601
+ }
573
602
  }
574
603
 
575
604
  /**
@@ -757,7 +786,12 @@ export class Tasks {
757
786
  // The gone owner hears it on its next delivery, if it comes back mid-work.
758
787
  if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
759
788
  } catch (e) {
760
- this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
789
+ if (e instanceof AssignmentUndeliveredError) {
790
+ // The move is fact for the board, so it is fact here too: record it, and tell the gone owner to stop
791
+ // (the console notice was printed where the error was thrown) (#297).
792
+ moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner });
793
+ if (e.task.owner && e.task.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${e.task.owner} while you were offline; stop working on it`));
794
+ } else this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
761
795
  }
762
796
  }
763
797
  } finally {
@@ -766,9 +800,8 @@ export class Tasks {
766
800
  return moved;
767
801
  }
768
802
 
769
- /** Same code as assignment, without doing it. */
770
- explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
771
- const routing = this.d.routing();
803
+ /** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
804
+ explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
772
805
  if (typeof target === "number") {
773
806
  const task = this.d.board.get(target);
774
807
  if (!task) throw new Error(`no task #${target}`);
@@ -784,8 +817,8 @@ export class Tasks {
784
817
  }
785
818
 
786
819
  /** Public read path: every generated line is screened, including profile/config-derived trace fields. */
787
- publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
788
- return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
820
+ publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
821
+ return this.explain(target, routing).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
789
822
  }
790
823
 
791
824
  /**
@@ -801,7 +834,8 @@ export class Tasks {
801
834
  */
802
835
  private passedOver(task: Task, a: Assignment): string {
803
836
  if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
804
- this.d.notify(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
837
+ // Before the write, but after the caller's own (a decline's, say): a throwing console fails nothing (#297).
838
+ this.releaseNotice(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
805
839
  return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
806
840
  }
807
841
 
@@ -826,12 +860,12 @@ export class Tasks {
826
860
  const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
827
861
 
828
862
  if (waits.length) {
829
- this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
863
+ this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
830
864
  return task;
831
865
  }
832
866
  const passed = this.passedOver(task, a);
833
867
  if (!a.owner) {
834
- this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
868
+ this.releaseNotice(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
835
869
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
836
870
  if (opts.clearOnFail && task.owner) {
837
871
  this.cohorts.leave(task.id);
@@ -856,19 +890,38 @@ export class Tasks {
856
890
  // A claim is its own hand-over: the claimant took the task in the turn it is in, so it is taking it, not busy elsewhere.
857
891
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
858
892
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
859
- const hits = this.overlapHits(next);
860
- this.formCohort(next, hits);
861
- if (hits.length) {
862
- this.announceOverlap(next, hits);
863
- this.tellEarlierOwners(next, hits);
893
+ try {
894
+ const hits = this.overlapHits(next);
895
+ this.formCohort(next, hits);
896
+ if (hits.length) {
897
+ this.announceOverlap(next, hits);
898
+ this.tellEarlierOwners(next, hits);
899
+ }
900
+ this.announceRouting(next, a);
901
+ if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
902
+ } catch (e) {
903
+ // Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
904
+ // delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
905
+ // control flow by the error type.
906
+ let saved = next;
907
+ try {
908
+ saved = this.d.board.get(task.id) ?? next;
909
+ } catch { /* the write's row is still true */ }
910
+ // Superseded means a later entry that moves or settles the task: another assignment, an unassign, the
911
+ // owner's accept, done, approval, close. Entries that do neither (the hub's quiet bookkeeping, a check
912
+ // result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
913
+ // the typed error stand (#297 Decisions).
914
+ const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
915
+ if (moves(saved.history).length !== moves(next.history).length) throw e;
916
+ this.releaseNotice(this.undeliveredNotice(saved, e));
917
+ throw new AssignmentUndeliveredError(saved, e);
864
918
  }
865
- this.announceRouting(next, a);
919
+ // A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
866
920
  if (opts.claim && a.owner === by) {
867
921
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
868
- this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
922
+ this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
869
923
  return claimed;
870
924
  }
871
- await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
872
925
  return next;
873
926
  }
874
927
 
@@ -920,9 +973,30 @@ export class Tasks {
920
973
  }
921
974
  }
922
975
 
923
- /** Notifications may read the same unavailable board; they must not undo a saved approval's return. */
924
- private releaseNotice(line: string): void {
925
- try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
976
+ /**
977
+ * The one guard for a publish or notify after a board write (#231, #243): the write took effect, so a failure
978
+ * never throws back to the caller. A failed publish becomes a console notice naming what was lost; a failed notify
979
+ * is dropped (a second attempt would fail the same way). A lost publish is not retried here: the delivery
980
+ * journal's own recovery covers a storage error.
981
+ */
982
+ private releaseNotice(line: string, run?: () => unknown): void {
983
+ if (!run) {
984
+ try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
985
+ return;
986
+ }
987
+ try { run(); } catch (e) {
988
+ try { this.d.notify(`${line}: ${e instanceof Error ? e.message : String(e)}`); } catch { /* best effort */ }
989
+ }
990
+ }
991
+
992
+ /**
993
+ * The one text for a saved assignment whose delivery is unconfirmed (#297), built through publicTitle (a PII task
994
+ * shows nothing more than it does today): what to check, and how to send it again. No automatic resend: a blind
995
+ * resend can duplicate a publish that did leave (the #276 decision).
996
+ */
997
+ undeliveredNotice(task: Task, cause: unknown): string {
998
+ const owner = task.owner ?? "its owner";
999
+ return `task ${this.publicTitle(task)}: the move to ${owner} is saved, but its assignment's delivery is not confirmed (${cause instanceof Error ? cause.message : String(cause)}); look for a queued or needs_review delivery with ahub queue list --peer ${owner} (then ahub queue show <id>), or the accepted entry in ahub task show ${task.id} if the journal is down; send it again with: ahub task assign ${task.id} ${owner}`;
926
1000
  }
927
1001
 
928
1002
  private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
@@ -947,7 +1021,7 @@ export class Tasks {
947
1021
 
948
1022
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
949
1023
  private async offerReady(stale: Task, why: string): Promise<void> {
950
- let offering = false;
1024
+ let offering = false, undelivered: AssignmentUndeliveredError | undefined;
951
1025
  try {
952
1026
  const t = this.d.board.get(stale.id);
953
1027
  if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
@@ -957,9 +1031,12 @@ export class Tasks {
957
1031
  this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
958
1032
  await this.assignOwner(ready, HUB);
959
1033
  } catch (e) {
960
- this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
1034
+ // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
1035
+ // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
1036
+ if (e instanceof AssignmentUndeliveredError) undelivered = e;
1037
+ else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
961
1038
  } finally {
962
- if (offering) {
1039
+ if (offering && !undelivered) {
963
1040
  try {
964
1041
  if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
965
1042
  } catch (e) {
@@ -1125,7 +1202,7 @@ export class Tasks {
1125
1202
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
1126
1203
  this.checking.set(task.id, task.owner);
1127
1204
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
1128
- this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
1205
+ this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
1129
1206
  const seen = { events: pending.history.length, owner: pending.owner };
1130
1207
  this.pendingChecks++;
1131
1208
  this.checkQueue = this.checkQueue
@@ -1255,11 +1332,11 @@ export class Tasks {
1255
1332
  this.tellCompleted(next, summary, withheld);
1256
1333
  if (!reviewer) {
1257
1334
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
1258
- this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
1335
+ this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
1259
1336
  await this.releaseDependents(next);
1260
1337
  }
1261
- else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
1262
- else this.sendReview(next, reviewer);
1338
+ else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
1339
+ else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
1263
1340
  return next;
1264
1341
  }
1265
1342
 
@@ -1279,7 +1356,8 @@ export class Tasks {
1279
1356
  cohort.held.add(task.id);
1280
1357
  continue;
1281
1358
  }
1282
- this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld));
1359
+ this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
1360
+ this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld)));
1283
1361
  }
1284
1362
  }
1285
1363
 
@@ -1357,7 +1435,8 @@ export class Tasks {
1357
1435
  }
1358
1436
  }
1359
1437
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
1360
- this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii);
1438
+ this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
1439
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
1361
1440
  await this.releaseDependents(next);
1362
1441
  return next;
1363
1442
  }
@@ -1367,14 +1446,27 @@ export class Tasks {
1367
1446
  this.contradict(rejected);
1368
1447
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
1369
1448
  if (rejected.rejections >= ESCALATE_AFTER) {
1370
- const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1449
+ let moved: Task;
1450
+ try {
1451
+ moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
1452
+ } catch (e) {
1453
+ // The verdict is saved. If the escalation's move was saved too, only its delivery is unconfirmed, and the
1454
+ // error carries the saved task (#297); the console notice was printed where the error was thrown. The
1455
+ // reviewer gets the saved task back, never a failed tool call (#276). Anything else (the task closed
1456
+ // meanwhile (#254), a failed board write, a superseded move) still throws. assignOwner keeps throwing for
1457
+ // the ready offer's retry (#231).
1458
+ if (!(e instanceof AssignmentUndeliveredError)) throw e;
1459
+ return e.task;
1460
+ }
1371
1461
  if (moved.owner !== rejected.owner) return moved;
1372
1462
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
1373
- this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
1463
+ this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
1464
+ this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii));
1374
1465
  return moved;
1375
1466
  }
1376
1467
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
1377
- this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii);
1468
+ this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
1469
+ this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii));
1378
1470
  return reopened;
1379
1471
  }
1380
1472
 
@@ -1389,12 +1481,28 @@ export class Tasks {
1389
1481
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
1390
1482
  // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
1391
1483
  if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
1392
- const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
1484
+ // The guarded tail of a landed move: the console notice, the decision note and the old owner's stop message,
1485
+ // each behind its own releaseNotice. It runs on the success path and on a saved move whose delivery failed.
1486
+ const tail = (moved: Task) => {
1487
+ this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
1488
+ this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
1489
+ if (from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
1490
+ this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
1491
+ };
1492
+ let next: Task;
1493
+ try {
1494
+ next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
1495
+ } catch (e) {
1496
+ // A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
1497
+ // the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
1498
+ // the write gets neither.
1499
+ if (e instanceof AssignmentUndeliveredError) tail(e.task);
1500
+ throw e;
1501
+ }
1393
1502
  if (next.owner && next.owner !== from) {
1394
- this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
1395
- this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
1396
- if (from) this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next));
1397
- } else this.d.notify(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
1503
+ // The move is on the board (#243): review() reaches here after its changes_requested write.
1504
+ tail(next);
1505
+ } else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
1398
1506
  return this.d.board.get(next.id)!;
1399
1507
  }
1400
1508
 
@@ -1404,7 +1512,7 @@ export class Tasks {
1404
1512
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
1405
1513
  if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
1406
1514
  const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
1407
- this.d.notify(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
1515
+ this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
1408
1516
  return next;
1409
1517
  }
1410
1518
 
@@ -1425,8 +1533,15 @@ export class Tasks {
1425
1533
  const pii = this.isPii(task);
1426
1534
  const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
1427
1535
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
1428
- const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
1429
- moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1536
+ try {
1537
+ const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
1538
+ moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
1539
+ } catch (e) {
1540
+ // A saved move whose delivery failed: the console notice was printed where the error was thrown; the
1541
+ // task counts as moved and the relay goes on (#297). No retry is needed for it: the notice already spoke.
1542
+ if (!(e instanceof AssignmentUndeliveredError)) throw e;
1543
+ moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner, role: "owner" });
1544
+ }
1430
1545
  } else if (task.reviewer === peer && task.state !== "approved") {
1431
1546
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
1432
1547
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
@@ -1475,7 +1590,7 @@ export class Tasks {
1475
1590
  if (this.isPii(task) || !this.d.memory) return;
1476
1591
  // claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
1477
1592
  // not clear (#198), is not sent (#69).
1478
- if (withheld || !this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
1593
+ if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
1479
1594
  void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
1480
1595
  }
1481
1596
 
@@ -0,0 +1,64 @@
1
+ import { spawn } from "node:child_process";
2
+ import { personEnv } from "./child-process.ts";
3
+ import { openStartTerminal, orcaExecutable, shellQuote, startTerminalWorktree, type CommandRunner } from "../cli/terminal-recovery.ts";
4
+
5
+ /** #269: where a terminal for a hub-made start comes from, or why there is none. */
6
+ export type TerminalOpened = { ok: true; via: string } | { ok: false; why: string; detail?: string };
7
+ export interface TerminalOpener {
8
+ /** Whether a start could open a terminal now; opens nothing. */
9
+ available(): TerminalOpened;
10
+ /** A terminal running `argv`, which the caller assembled from fixed words: nothing here adds to it. */
11
+ open(title: string, argv: readonly string[]): Promise<TerminalOpened>;
12
+ }
13
+
14
+ // ponytail: a template command still running after this long is taken to be the terminal itself, so one that fails
15
+ // later is reported as opened; a provider-specific readiness check (as recovery has for Orca) if that ever misleads.
16
+ const SETTLE_MS = 1500;
17
+ const PLACEHOLDER = /\{(command|title|cwd)\}/g;
18
+
19
+ /**
20
+ * The providers, in order: the machine-local `terminal.open` command template, then Orca (the path recovery uses)
21
+ * when this project has an Orca worktree. `run` replaces the Orca CLI in tests.
22
+ */
23
+ export function terminalOpener(opts: { template: string[]; cwd: string; stateDir: string; env?: NodeJS.ProcessEnv; run?: CommandRunner }): TerminalOpener {
24
+ const env = opts.env ?? process.env;
25
+ const worktree = () => startTerminalWorktree(opts.cwd, opts.stateDir, env);
26
+ const available = (): TerminalOpened => {
27
+ if (opts.template.length) return { ok: true, via: "terminal.open" };
28
+ if (!worktree()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
29
+ if (!opts.run && !Bun.which(orcaExecutable())) return { ok: false, why: `the ${orcaExecutable()} command is not on PATH` };
30
+ return { ok: true, via: "orca" };
31
+ };
32
+ return {
33
+ available,
34
+ async open(title, argv) {
35
+ const can = available();
36
+ if (!can.ok) return can;
37
+ // One command line, each word quoted for a POSIX shell: the terminal's shell runs exactly these words.
38
+ const command = argv.map(shellQuote).join(" ");
39
+ try {
40
+ if (can.via === "orca") {
41
+ await openStartTerminal(worktree()!, command, title, opts.run ? { runner: opts.run } : undefined);
42
+ return can;
43
+ }
44
+ // One pass with a function: a placeholder inside a substituted value (a project path that contains "{cwd}")
45
+ // is not expanded again, and "$" in a path is not a replacement pattern. Only {command} is shell-quoted;
46
+ // {title} and {cwd} are raw text for an argv element of their own.
47
+ const values: Record<string, string> = { command, title, cwd: opts.cwd };
48
+ const [bin, ...args] = opts.template.map((part) => part.replace(PLACEHOLDER, (_, name: string) => values[name]!));
49
+ // Detached and unwatched: the terminal is the person's and outlives the hub.
50
+ const child = spawn(bin!, args, { cwd: opts.cwd, env: personEnv(env), stdio: "ignore", detached: true });
51
+ const failed = await new Promise<string | undefined>((resolve) => {
52
+ const timer = setTimeout(() => resolve(undefined), SETTLE_MS); // still running: it is the terminal itself
53
+ child.once("error", (error) => { clearTimeout(timer); resolve(`terminal.open could not start (${(error as NodeJS.ErrnoException).code ?? "error"})`); });
54
+ child.once("exit", (code, signal) => { clearTimeout(timer); resolve(code === 0 ? undefined : `terminal.open exited ${signal ?? code}`); });
55
+ });
56
+ child.unref();
57
+ return failed ? { ok: false, why: failed } : can;
58
+ } catch (error) {
59
+ // The provider's own text can name paths and its stderr: it goes to the hub's log, not to the caller.
60
+ return { ok: false, why: can.via === "orca" ? "Orca could not create the terminal" : "terminal.open could not be run", detail: (error as Error).message };
61
+ }
62
+ },
63
+ };
64
+ }
package/src/hub/ui.ts CHANGED
@@ -1,10 +1,13 @@
1
+ import { PEER_START_RETRY_MS } from "./start-mode.ts";
1
2
  import { taskProgress } from "../ui/task-progress.ts";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { readFileSync } from "node:fs";
4
5
 
6
+ /** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
7
+ export interface DashboardSession { settings: boolean; settingsUntil?: number }
5
8
  export interface DashboardOptions {
6
- snapshot: (after: number, input?: Record<string, unknown>) => unknown | Promise<unknown>;
7
- action: (input: Record<string, unknown>) => unknown | Promise<unknown>;
9
+ snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
10
+ action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
8
11
  projects?: () => unknown | Promise<unknown>;
9
12
  selectedProjectId?: string;
10
13
  /** Injectable clock for expiry tests; production uses wall time. */
@@ -13,17 +16,20 @@ export interface DashboardOptions {
13
16
 
14
17
  const TICKET_MS = 60_000;
15
18
  const SESSION_MS = 60 * 60_000;
19
+ /** How long a session opened by `ahub ui --settings` may change settings that raise authority; it stays an ordinary session after. */
20
+ export const SETTINGS_MS = 15 * 60_000;
16
21
  const LIMIT = 64;
17
22
  const secret = () => randomBytes(32).toString("hex");
18
23
 
19
24
  /** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
20
25
  export function startDashboard(options: DashboardOptions) {
21
26
  const now = options.now ?? Date.now;
22
- const tickets = new Map<string, number>();
23
- const sessions = new Map<string, number>();
27
+ const tickets = new Map<string, { expires: number; settings: boolean }>();
28
+ const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
24
29
  // Inject the same pure public-board model used by the console before computing CSP hashes.
25
30
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
26
- .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`);
31
+ .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
32
+ .replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`);
27
33
  const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
28
34
  .map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
29
35
  const headers = {
@@ -36,9 +42,13 @@ export function startDashboard(options: DashboardOptions) {
36
42
  const json = (body: unknown, status = 200, extra: Record<string, string> = {}) =>
37
43
  new Response(JSON.stringify(body), { status, headers: { ...headers, "Content-Type": "application/json", ...extra } });
38
44
  const reject = (status: number, error: string) => json({ ok: false, error }, status);
39
- function prune(map: Map<string, number>) {
40
- for (const [key, expires] of map) if (expires <= now()) map.delete(key);
45
+ function prune(map: Map<string, { expires: number }>) {
46
+ for (const [key, entry] of map) if (entry.expires <= now()) map.delete(key);
41
47
  }
48
+ const sessionOf = (cookie: string | undefined): DashboardSession => {
49
+ const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
50
+ return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
51
+ };
42
52
  const server = Bun.serve({
43
53
  hostname: "127.0.0.1",
44
54
  port: 0,
@@ -75,10 +85,11 @@ export function startDashboard(options: DashboardOptions) {
75
85
  if (path !== "/session" && (!cookie || !sessions.has(cookie))) return reject(401, "session expired; run ahub ui");
76
86
  if (path === "/session") {
77
87
  const ticket = typeof input.ticket === "string" ? input.ticket : "";
78
- if (!tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
88
+ const issued = tickets.get(ticket);
89
+ if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
79
90
  if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
80
91
  const session = secret();
81
- sessions.set(session, now() + SESSION_MS);
92
+ sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
82
93
  return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
83
94
  }
84
95
  if (path === "/projects") {
@@ -97,7 +108,7 @@ export function startDashboard(options: DashboardOptions) {
97
108
  const after = input.after ?? 0;
98
109
  if (typeof after !== "number" || !Number.isSafeInteger(after) || after < 0) return reject(400, "invalid cursor");
99
110
  try {
100
- const result = await options.snapshot(after, input);
111
+ const result = await options.snapshot(after, input, sessionOf(cookie));
101
112
  if (!result || typeof result !== "object" || Array.isArray(result)) return json(result);
102
113
  const response = { ...(result as Record<string, unknown>) };
103
114
  if (typeof response.projectId === "string" && typeof input.projectId === "string" && response.projectId !== input.projectId) return reject(409, "snapshot project changed; refresh the dashboard");
@@ -110,7 +121,7 @@ export function startDashboard(options: DashboardOptions) {
110
121
  }
111
122
  }
112
123
  try {
113
- return json(await options.action(input));
124
+ return json(await options.action(input, sessionOf(cookie)));
114
125
  } catch {
115
126
  // Exceptions may quote private task text or backend details. Never echo them to the page.
116
127
  return reject(400, "action failed; check its inputs and the terminal");
@@ -125,11 +136,12 @@ export function startDashboard(options: DashboardOptions) {
125
136
  const cookieName = `ahub_ui_${server.port}`;
126
137
  return {
127
138
  origin,
128
- issue(): string {
139
+ /** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
140
+ issue(settings = false): string {
129
141
  prune(tickets);
130
142
  if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
131
143
  const ticket = secret();
132
- tickets.set(ticket, now() + TICKET_MS);
144
+ tickets.set(ticket, { expires: now() + TICKET_MS, settings });
133
145
  return `${origin}/#${ticket}`;
134
146
  },
135
147
  stop() {