@staix/agent-hub 0.12.22 → 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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/docs/agent-notes/adapters.md +10 -5
  4. package/docs/agent-notes/benchmarks.md +3 -0
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/daemon.md +8 -1
  7. package/docs/agent-notes/local-worker.md +3 -2
  8. package/docs/agent-notes/tasks.md +2 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +7 -3
  11. package/docs/operations.md +232 -26
  12. package/docs/quickstart.md +9 -4
  13. package/docs/security.md +15 -6
  14. package/docs/smoke.md +72 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
  16. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  17. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  18. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  19. package/docs/verified.json +25 -16
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +14 -3
  23. package/src/adapters/acp-quota.ts +13 -0
  24. package/src/adapters/acp.ts +95 -13
  25. package/src/adapters/codex-appserver.ts +43 -13
  26. package/src/cli/bench.ts +2 -1
  27. package/src/cli/console-state.ts +28 -22
  28. package/src/cli/console.ts +20 -10
  29. package/src/cli/help.ts +19 -14
  30. package/src/cli/identity.ts +2 -2
  31. package/src/cli/init.ts +1 -1
  32. package/src/cli/main.ts +272 -159
  33. package/src/cli/output.ts +363 -0
  34. package/src/cli/preview.ts +2 -1
  35. package/src/cli/reset.ts +3 -0
  36. package/src/cli/status-lines.ts +0 -28
  37. package/src/cli/terminal-recovery.ts +27 -1
  38. package/src/cli/upgrade-runtime.ts +24 -7
  39. package/src/cli/upgrade.ts +23 -5
  40. package/src/hub/board.ts +2 -0
  41. package/src/hub/budget.ts +39 -7
  42. package/src/hub/bus.ts +10 -11
  43. package/src/hub/child-process.ts +11 -0
  44. package/src/hub/conductor.ts +23 -11
  45. package/src/hub/config-trust.ts +16 -5
  46. package/src/hub/conflicts.ts +2 -0
  47. package/src/hub/context-window.ts +8 -1
  48. package/src/hub/crash.ts +2 -1
  49. package/src/hub/daemon.ts +338 -23
  50. package/src/hub/db-timeout.ts +3 -0
  51. package/src/hub/delivery-journal.ts +2 -1
  52. package/src/hub/envelope.ts +1 -1
  53. package/src/hub/events.ts +2 -0
  54. package/src/hub/execution-budget.ts +2 -0
  55. package/src/hub/facts.ts +5 -4
  56. package/src/hub/git.ts +32 -0
  57. package/src/hub/hub-tools.ts +1 -1
  58. package/src/hub/lifecycle.ts +10 -4
  59. package/src/hub/manager.ts +18 -5
  60. package/src/hub/peers.ts +5 -3
  61. package/src/hub/permission-mode.ts +14 -0
  62. package/src/hub/project.ts +2 -1
  63. package/src/hub/recovery-error.ts +31 -0
  64. package/src/hub/recovery-store.ts +6 -5
  65. package/src/hub/registry.ts +2 -1
  66. package/src/hub/routing.ts +114 -13
  67. package/src/hub/settings.ts +392 -0
  68. package/src/hub/snapshots.ts +4 -2
  69. package/src/hub/start-mode.ts +48 -0
  70. package/src/hub/tasks.ts +129 -31
  71. package/src/hub/terminal-open.ts +64 -0
  72. package/src/hub/ui.ts +25 -13
  73. package/src/local/deny.ts +29 -7
  74. package/src/local/sandbox.ts +44 -6
  75. package/src/memory/recall.ts +2 -2
  76. package/src/models/mlx.ts +2 -1
  77. package/src/pi/tool-receipts.ts +2 -1
  78. package/src/ui/index.html +157 -10
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
 
@@ -936,6 +989,16 @@ export class Tasks {
936
989
  }
937
990
  }
938
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}`;
1000
+ }
1001
+
939
1002
  private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
940
1003
  private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
941
1004
 
@@ -958,7 +1021,7 @@ export class Tasks {
958
1021
 
959
1022
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
960
1023
  private async offerReady(stale: Task, why: string): Promise<void> {
961
- let offering = false;
1024
+ let offering = false, undelivered: AssignmentUndeliveredError | undefined;
962
1025
  try {
963
1026
  const t = this.d.board.get(stale.id);
964
1027
  if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
@@ -968,9 +1031,12 @@ export class Tasks {
968
1031
  this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
969
1032
  await this.assignOwner(ready, HUB);
970
1033
  } catch (e) {
971
- 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)}`);
972
1038
  } finally {
973
- if (offering) {
1039
+ if (offering && !undelivered) {
974
1040
  try {
975
1041
  if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
976
1042
  } catch (e) {
@@ -1380,7 +1446,18 @@ export class Tasks {
1380
1446
  this.contradict(rejected);
1381
1447
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
1382
1448
  if (rejected.rejections >= ESCALATE_AFTER) {
1383
- 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
+ }
1384
1461
  if (moved.owner !== rejected.owner) return moved;
1385
1462
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
1386
1463
  this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
@@ -1404,13 +1481,27 @@ export class Tasks {
1404
1481
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
1405
1482
  // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
1406
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 });
1407
- 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
+ }
1408
1502
  if (next.owner && next.owner !== from) {
1409
1503
  // The move is on the board (#243): review() reaches here after its changes_requested write.
1410
- this.releaseNotice(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
1411
- this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
1412
- if (from) this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${from} it moved to ${next.owner}`, () =>
1413
- this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next)));
1504
+ tail(next);
1414
1505
  } else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
1415
1506
  return this.d.board.get(next.id)!;
1416
1507
  }
@@ -1421,7 +1512,7 @@ export class Tasks {
1421
1512
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
1422
1513
  if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
1423
1514
  const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
1424
- 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`);
1425
1516
  return next;
1426
1517
  }
1427
1518
 
@@ -1442,8 +1533,15 @@ export class Tasks {
1442
1533
  const pii = this.isPii(task);
1443
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);
1444
1535
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
1445
- const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
1446
- 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
+ }
1447
1545
  } else if (task.reviewer === peer && task.state !== "approved") {
1448
1546
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
1449
1547
  const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
@@ -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() {
package/src/local/deny.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, sep } from "node:path";
1
+ import { basename, dirname, sep } from "node:path";
2
2
 
3
3
  /**
4
4
  * The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
@@ -50,13 +50,23 @@ const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) =>
50
50
  * rename, symlink or gitfile, and `config` and `hooks` directly under it are folded too; the cost is that
51
51
  * `git init`, `git clone` and `git worktree add` no longer run inside the sandbox. `commondir` is refused at any
52
52
  * depth below a `.git` and below each external git dir: that is where git keeps it for linked worktrees and
53
- * submodules. An external git dir's `config`/`hooks` rules cover only its own children (plus the hooks
54
- * directory's contents): at any depth they would deny refs named `config` or `hooks` in a worktree or submodule
55
- * project (`git branch fix/config` fails on `.git/logs/refs/heads/fix/config`). Every pattern keeps its `^`
56
- * anchor (an unanchored starred alternation does not match in this engine) and no folded literal contains the
57
- * root: an SBPL string literal dies at 1024 bytes ("Error reading string"), which a long project path would trip.
53
+ * submodules. A submodule's git dir under `.git/modules/` gets the same `config`/`hooks` refusal at whatever depth
54
+ * submodules nest (a nested one lives under `.git/modules/<a>/modules/<b>`, #281), anchored at the discovered
55
+ * modules root so a directory named `refs`, `config` or `hooks` ABOVE the project neither disables the rule nor
56
+ * trips it; a `/refs/` or `/logs/` segment below the root keeps a branch or tag named `config` or `hooks` writable
57
+ * (the round-3 regression of #277), at the price of leaving a submodule whose own path holds a `refs` or `logs`
58
+ * segment unprotected. The basenames are folded; a folded INTERMEDIATE component on HFS+ (a spelling of `modules`)
59
+ * is a known residual, the same one the external git dir rules carry. The directory entries above those files are
60
+ * protected as the `.git` name is: the discovered git dirs, the modules root and every component in between are
61
+ * refused as targets (rename, replace, symlink, recreate). An external git dir's `config`/`hooks` rules cover only
62
+ * its own children (plus the hooks directory's contents): at any depth they would deny refs named `config` or
63
+ * `hooks` in a worktree or submodule project (`git branch fix/config` fails on `.git/logs/refs/heads/fix/config`).
64
+ * Every pattern keeps its `^` anchor (an unanchored starred alternation does not match in this engine) and no
65
+ * folded literal contains the root: an SBPL string literal dies at 1024 bytes ("Error reading string"), which a
66
+ * long project path would trip. ponytail: the modules rules do contain the (plain) modules root; a project path
67
+ * over roughly 600 bytes with submodules would exceed the limit — fold the root's segments if one ever shows up.
58
68
  */
59
- export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[] {
69
+ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit?: { root: string; entries: string[] }): string[] {
60
70
  const git = hfsFoldedName(".git");
61
71
  const re = (s: string) => `(regex ${sbplString(s)})`;
62
72
  const external = gitDirs.flatMap((d) => [
@@ -64,12 +74,24 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[]
64
74
  `(subpath ${sbplString(`${d}/hooks`)})`,
65
75
  `(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
66
76
  ]);
77
+ const modules = moduleGit
78
+ ? [
79
+ // Every name matches whole components only: `(.*/)?` forces a boundary, so a submodule named `catalogs`
80
+ // or `prefs` keeps the refusal and one named `webhooks` keeps its ordinary writes (#281 round 2).
81
+ `(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("config")}$`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
82
+ `(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("hooks")}(/|$)`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
83
+ // A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is
84
+ // folded (HFS+ opens the folded spelling as the real entry); the plain prefix is the path as discovered.
85
+ ...moduleGit.entries.map((d) => re(`^${sbplEscape(dirname(d))}/${hfsFoldedName(basename(d))}$`)),
86
+ ]
87
+ : [];
67
88
  return [
68
89
  `(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
69
90
  re(`^.*/${git}$`),
70
91
  re(`^.*/${git}/${hfsFoldedName("config")}$`),
71
92
  re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
72
93
  `(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
94
+ ...modules,
73
95
  ...external,
74
96
  ];
75
97
  }
@@ -1,8 +1,9 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync, type Dirent } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
6
+ import { hubGitSync } from "../hub/git.ts";
6
7
  import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
7
8
 
8
9
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
@@ -40,12 +41,47 @@ function developerDir(): string | undefined {
40
41
 
41
42
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
42
43
  function externalGitDirs(root: string): string[] {
43
- const out = spawnSync("git", ["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
44
+ // Runs in the hub process, outside the sandbox: the hardened helper keeps the repo's config from naming a program (#281).
45
+ const out = hubGitSync(["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
44
46
  if (out.status !== 0) return [];
45
47
  const dirs = out.stdout.trim().split("\n").map((d) => resolve(root, d));
46
48
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
47
49
  }
48
50
 
51
+ /**
52
+ * The project's submodule git directories live under the common dir's `modules/` tree (the worktree case included:
53
+ * it is the main repo's `.git/modules`). Returns the modules root and every directory entry that must not be
54
+ * renamed, replaced, symlinked or recreated: each discovered git dir (a directory holding HEAD and config) and
55
+ * every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
56
+ */
57
+ function moduleGitDirs(root: string): { root: string; entries: string[] } | undefined {
58
+ const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
59
+ if (out.status !== 0) return undefined;
60
+ const modulesRoot = join(resolve(root, out.stdout.trim()), "modules");
61
+ const dirs: string[] = [];
62
+ const walk = (dir: string, depth: number): void => {
63
+ if (depth > 8) return; // submodules do not nest deeper in practice; the regex rules still cover the names
64
+ let entries: Dirent[];
65
+ try {
66
+ entries = readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return; // no modules tree (no submodules): nothing to discover
69
+ }
70
+ const names = new Set(entries.map((e) => e.name));
71
+ if (names.has("HEAD") && names.has("config")) {
72
+ dirs.push(dir);
73
+ const nested = join(dir, "modules");
74
+ if (names.has("modules")) walk(nested, depth + 1); // a submodule of a submodule
75
+ return;
76
+ }
77
+ for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
78
+ };
79
+ walk(modulesRoot, 0);
80
+ const entries = new Set<string>([modulesRoot]);
81
+ for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
82
+ return { root: modulesRoot, entries: [...entries] };
83
+ }
84
+
49
85
  /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
50
86
  const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
51
87
  /**
@@ -74,6 +110,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
74
110
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
75
111
  const root = realPath(cwd);
76
112
  const gitDirs = externalGitDirs(root);
113
+ const moduleGit = moduleGitDirs(root);
77
114
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
78
115
  const dev = developerDir();
79
116
  const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
@@ -104,9 +141,10 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
104
141
  `(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
105
142
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
106
143
  // .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
107
- // at any depth (creation, rename, symlink, gitfile), and the external git dirs get folded hooks/config rules
108
- // (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
109
- `(deny file-write* ${hubWriteRegexes(root, gitDirs).join(" ")})`,
144
+ // at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
145
+ // .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
146
+ // hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
147
+ `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
110
148
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
111
149
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
112
150
  // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
@@ -1,7 +1,7 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { basename, dirname, resolve } from "node:path";
3
2
  import { readFileSync } from "node:fs";
4
3
  import type { PeerId } from "../hub/envelope.ts";
4
+ import { hubGitSync } from "../hub/git.ts";
5
5
  import type { MemoryClient } from "./client.ts";
6
6
 
7
7
  const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
@@ -14,7 +14,7 @@ const HEADER =
14
14
 
15
15
  /** Project names from the outermost superproject down to this repo. */
16
16
  export function projectChain(cwd: string): string[] {
17
- const git = (dir: string, arg: string) => spawnSync("git", ["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
17
+ const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
18
18
  const top = git(cwd, "--show-toplevel");
19
19
  if (!top) return [basename(cwd)];
20
20