@staix/agent-hub 0.12.39 → 0.12.40

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 (77) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +15 -10
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +2 -0
  5. package/docs/agent-notes/bus.md +2 -2
  6. package/docs/agent-notes/daemon.md +10 -6
  7. package/docs/agent-notes/models.md +4 -4
  8. package/docs/agent-notes/tasks.md +3 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +152 -28
  12. package/docs/quickstart.md +19 -5
  13. package/docs/security.md +19 -7
  14. package/docs/smoke.md +2 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  17. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  18. package/docs/verified.json +38 -19
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +413 -147
  22. package/src/adapters/acp.ts +34 -1
  23. package/src/adapters/claude-channel.ts +35 -4
  24. package/src/adapters/codex-appserver.ts +12 -3
  25. package/src/adapters/definitions/builtin-metadata.ts +2 -0
  26. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  27. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  28. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
  30. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  33. package/src/adapters/jcode.ts +110 -4
  34. package/src/adapters/local-worker.ts +21 -37
  35. package/src/adapters/pi.ts +7 -0
  36. package/src/cli/console-state.ts +1 -0
  37. package/src/cli/console.ts +11 -1
  38. package/src/cli/help.ts +13 -8
  39. package/src/cli/identity-audit.ts +33 -7
  40. package/src/cli/jcode-mcp.ts +1 -1
  41. package/src/cli/main.ts +134 -24
  42. package/src/cli/output.ts +5 -2
  43. package/src/cli/status-lines.ts +1 -1
  44. package/src/cli/terminal-recovery.ts +54 -4
  45. package/src/cli/upgrade-interactive.ts +5 -0
  46. package/src/cli/upgrade-runtime.ts +13 -1
  47. package/src/cli/upgrade.ts +67 -1
  48. package/src/hub/board.ts +4 -2
  49. package/src/hub/bus.ts +8 -3
  50. package/src/hub/checks.ts +15 -5
  51. package/src/hub/conductor.ts +2 -1
  52. package/src/hub/config-apply.ts +20 -2
  53. package/src/hub/config-reload.ts +2 -8
  54. package/src/hub/control-client.ts +26 -3
  55. package/src/hub/daemon.ts +155 -68
  56. package/src/hub/delivery-superseded.ts +19 -2
  57. package/src/hub/events.ts +4 -1
  58. package/src/hub/hub-tools.ts +4 -4
  59. package/src/hub/inference.ts +4 -8
  60. package/src/hub/lifecycle.ts +3 -3
  61. package/src/hub/main-checkout.ts +36 -0
  62. package/src/hub/memory-profile-check.ts +6 -2
  63. package/src/hub/memory-profile.ts +18 -1
  64. package/src/hub/peers.ts +2 -0
  65. package/src/hub/ports.ts +1 -0
  66. package/src/hub/routing.ts +24 -14
  67. package/src/hub/settings.ts +1 -1
  68. package/src/hub/task-repository.ts +136 -0
  69. package/src/hub/tasks.ts +101 -21
  70. package/src/hub/terminal-launch.ts +130 -0
  71. package/src/hub/usage.ts +44 -18
  72. package/src/models/route/config.ts +1 -1
  73. package/src/omniroute/client.ts +7 -19
  74. package/templates/AGENTS.block.md +1 -0
  75. package/templates/routing.toml +10 -44
  76. package/src/switchyard/config.ts +0 -45
  77. package/src/switchyard/sidecar.ts +0 -136
package/src/hub/tasks.ts CHANGED
@@ -27,6 +27,8 @@ import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type A
27
27
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
28
28
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
29
29
  import { realPath } from "./project.ts";
30
+ import { TaskRepositories } from "./task-repository.ts";
31
+ import { mainCheckoutWarning } from "./main-checkout.ts";
30
32
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
31
33
  import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
32
34
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
@@ -228,6 +230,16 @@ function cleanRefs(input: unknown): TaskRefs {
228
230
  return out;
229
231
  }
230
232
 
233
+ /** Acceptance may correct repository metadata, never file intent or work evidence. */
234
+ function acceptRefs(input: unknown): TaskRefs | undefined {
235
+ if (input == null) return undefined;
236
+ if (typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(key => key !== "repo")) throw new PreEffectToolRefusal("accept refs may contain only repo");
237
+ const raw = input as Record<string, unknown>;
238
+ if (!Object.hasOwn(raw, "repo")) return undefined;
239
+ if (typeof raw.repo !== "string" || !raw.repo.trim() || raw.repo.length > 300) throw new PreEffectToolRefusal("accept refs.repo must be a bounded repository slug or path");
240
+ return { repo: text(raw.repo)! };
241
+ }
242
+
231
243
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
232
244
  function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
233
245
  const r = fields(input);
@@ -248,6 +260,7 @@ const RESCREEN_MAX = 10;
248
260
  * PII handling, envelopes, briefs and memory notes are decided in one place.
249
261
  */
250
262
  export class Tasks {
263
+ private readonly repositories: TaskRepositories;
251
264
  private readonly incomingBacklog = new Set<number>();
252
265
  private readonly plannerBacklog = new Map<PeerId, Set<number>>();
253
266
  private readonly plannerBlockedQueue = new Map<PeerId, string>();
@@ -259,6 +272,8 @@ export class Tasks {
259
272
  private sweeping = false;
260
273
 
261
274
  constructor(private readonly d: TasksDeps) {
275
+ this.repositories = new TaskRepositories(d.cwd, d.repository);
276
+ for (const task of d.board.list()) this.warmRepository(task);
262
277
  if (d.coordination) {
263
278
  const mode = d.coordination();
264
279
  for (const task of d.board.list()) this.coordinationModes.set(task.id, mode);
@@ -685,6 +700,7 @@ export class Tasks {
685
700
  const theirs = this.places(t);
686
701
  // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
687
702
  // were stored as written, so both sides are compared in one spelling.
703
+ if (mine.repositoryKey !== theirs.repositoryKey) continue;
688
704
  const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
689
705
  const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.includes(x));
690
706
  if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
@@ -728,6 +744,24 @@ export class Tasks {
728
744
  private plannerFull(task: Task): PeerId[] {
729
745
  return Object.keys(this.states()).filter(peer => { const queue = this.d.bus.queueSummary(peer); return this.wip(peer, task.id) >= this.wipLimit(peer) || !!queue.heldBy || queue.needsReview > 0; });
730
746
  }
747
+ /**
748
+ * Why a planner pull finds no owner (#531), named from what the assignment saw and never from task text: the peers a
749
+ * lasting exclusion keeps out, a peer at its WIP limit (with wip/limit), a held queue and needs_review. Peer ids and counts
750
+ * only, so the text is safe for a PII task too. `route explain` reads the same `excluded()` and `plannerFull` inputs.
751
+ */
752
+ plannerRefusal(task: Task): string {
753
+ const parts: string[] = [];
754
+ const excluded = [...new Set(this.excluded(task))];
755
+ if (excluded.length) parts.push(`${excluded.join(", ")} declined or was escalated away from this task for good; only a person's ahub task assign lifts that`);
756
+ for (const peer of Object.keys(this.states())) {
757
+ if (excluded.includes(peer)) continue;
758
+ const queue = this.d.bus.queueSummary(peer), wip = this.wip(peer, task.id), limit = this.wipLimit(peer);
759
+ if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
760
+ else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
761
+ else if (queue.needsReview > 0) parts.push(`${peer} has ${queue.needsReview} delivery(ies) in needs_review`);
762
+ }
763
+ return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
764
+ }
731
765
  private plannerAssignment(task: Task, reservedOnly = false): Assignment {
732
766
  const states = this.states();
733
767
  const full = this.plannerFull(task);
@@ -758,7 +792,7 @@ export class Tasks {
758
792
  })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
759
793
  }
760
794
  const assignment = this.plannerAssignment(task);
761
- if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
795
+ if (!assignment.owner) throw new PreEffectToolRefusal(this.plannerRefusal(task));
762
796
  if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
763
797
  return { task, owner: assignment.owner };
764
798
  };
@@ -899,6 +933,8 @@ export class Tasks {
899
933
  }
900
934
  if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
901
935
  if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
936
+ // #531 D3: a reservation for a peer routing leaves out for good could never be honoured. Say so, and who can lift it.
937
+ if (patch.reserved && this.excluded(task).includes(patch.reserved)) throw new PreEffectToolRefusal(`${patch.reserved} is excluded from this task (it declined it or was escalated away from it for good); a reservation for it can never be honoured. Only a person's ahub task assign lifts the exclusion`);
902
938
  if (patch.reserved) this.requireCapabilities(task, patch.reserved);
903
939
  const rows = new Map(this.d.board.list().map(row => [row.id, row]));
904
940
  const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
@@ -1044,6 +1080,7 @@ export class Tasks {
1044
1080
  const verdicts = screenedVerdicts.map((verdict, index) => deterministicPrivate[index] ? undefined : verdict);
1045
1081
  const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
1046
1082
  const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
1083
+ for (const draft of drafts) this.warmRepository(draft);
1047
1084
  const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
1048
1085
  if (this.d.coordination) {
1049
1086
  const mode = this.d.coordination();
@@ -1238,7 +1275,7 @@ export class Tasks {
1238
1275
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
1239
1276
  const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
1240
1277
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
1241
- const screenDetail = [text.detail, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
1278
+ const screenDetail = [text.detail, text.refs.repo, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
1242
1279
  const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
1243
1280
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
1244
1281
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
@@ -1268,7 +1305,10 @@ export class Tasks {
1268
1305
  const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
1269
1306
  if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
1270
1307
  if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
1308
+ this.warmRepository({ refs: draft.refs, plan });
1309
+ const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
1271
1310
  let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
1311
+ if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
1272
1312
  if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1273
1313
  created?.();
1274
1314
  if (screening) task = this.screenedTask(task, screened);
@@ -1553,9 +1593,15 @@ export class Tasks {
1553
1593
  * Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). Models write paths
1554
1594
  * absolute or relative: one inside the project is compared in its project-relative spelling.
1555
1595
  */
1556
- private places(task: Task): { paths: string[]; symbols: string[] } {
1557
- const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
1558
- return { paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
1596
+ private warmRepository(task: Pick<Task, "refs" | "plan">): void {
1597
+ const scope = this.repositories.scope(task.refs);
1598
+ if (scope !== this.repositories.project) for (const path of [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])]) this.repositories.relativePath(scope, path);
1599
+ }
1600
+
1601
+ private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
1602
+ const scope = this.repositories.scope(task.refs);
1603
+ const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
1604
+ return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
1559
1605
  }
1560
1606
 
1561
1607
  private roots?: string[];
@@ -1579,6 +1625,7 @@ export class Tasks {
1579
1625
  return this.d.board.list().flatMap((t) => {
1580
1626
  if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
1581
1627
  const theirs = this.places(t);
1628
+ if (mine.repositoryKey !== theirs.repositoryKey) return [];
1582
1629
  const paths = task.profile && t.profile && (task.plan?.sections?.length && t.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
1583
1630
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
1584
1631
  return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
@@ -1672,7 +1719,7 @@ export class Tasks {
1672
1719
  * Peers routing leaves out for good (#207): the owners a task was declined for (the console declines for one; older rows
1673
1720
  * name only the decliner) and the owners it was escalated away from, so a reservation never hands it back to them.
1674
1721
  */
1675
- private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
1722
+ private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" && h.capacity !== true ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
1676
1723
 
1677
1724
  private checkFlowRevision(task: Task, expected: number | undefined, preEffect = false): void {
1678
1725
  if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0 || task.history.length !== expected)) {
@@ -2442,18 +2489,22 @@ export class Tasks {
2442
2489
  const base = task.sdlc ? stageDefinition(config, task.sdlc.stage)?.base : undefined;
2443
2490
  return base === "build" || base === "implement";
2444
2491
  }
2445
- async acceptReviewed(by: PeerId, id: unknown, plan?: unknown): Promise<Task> {
2492
+ async acceptReviewed(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Promise<Task> {
2493
+ const metadata = acceptRefs(refs);
2446
2494
  const task = this.need(id, false, true);
2447
2495
  this.mine(task, by, "owner", true);
2448
2496
  this.acceptanceCapacity(task);
2449
- if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan);
2497
+ if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
2498
+ const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
2499
+ this.warmRepository(draft);
2500
+ if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan, metadata);
2450
2501
  await this.specGate(by, id);
2451
- await this.planEntryGate(by, task);
2502
+ await this.planEntryGate(by, draft);
2452
2503
  const current = this.need(id, false, true);
2453
2504
  this.mine(current, by, "owner", true);
2454
2505
  if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
2455
2506
  this.reviewedAdmissions.set(current.id, { flow: current.history.length, owner: current.owner });
2456
- try { return this.accept(by, id, plan); }
2507
+ try { return this.accept(by, id, plan, metadata); }
2457
2508
  finally { this.reviewedAdmissions.delete(current.id); }
2458
2509
  }
2459
2510
 
@@ -2494,7 +2545,8 @@ export class Tasks {
2494
2545
  * With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
2495
2546
  * announced like one found at assignment (issue #31).
2496
2547
  */
2497
- accept(by: PeerId, id: unknown, plan?: unknown): Task {
2548
+ accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
2549
+ const metadata = acceptRefs(refs);
2498
2550
  const task = this.need(id, false, true);
2499
2551
  this.mine(task, by, "owner", true);
2500
2552
  this.ready(task, true);
@@ -2521,10 +2573,15 @@ export class Tasks {
2521
2573
  if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
2522
2574
  throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
2523
2575
  }
2576
+ if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
2577
+ const draft = { ...task, refs: { ...task.refs, ...metadata }, ...(cleaned ? { plan: cleaned } : {}) };
2578
+ this.warmRepository(draft);
2579
+ const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
2524
2580
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
2525
- const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
2581
+ const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
2526
2582
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
2527
- if (cleaned) {
2583
+ if (repositoryChanged) this.cohorts.leave(next.id);
2584
+ if (cleaned || metadata) {
2528
2585
  const hits = this.overlapHits(next);
2529
2586
  this.formCohort(next, hits);
2530
2587
  const fresh = hits.filter((h) => !before.has(h.task.id));
@@ -2578,16 +2635,24 @@ export class Tasks {
2578
2635
  this.doneNotes.delete(id);
2579
2636
  return note;
2580
2637
  };
2638
+ /** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
2639
+ checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
2581
2640
 
2582
- async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
2641
+ /**
2642
+ * `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
2643
+ * (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
2644
+ * is every older row, which has no such field.
2645
+ */
2646
+ async decline(by: PeerId, id: unknown, reason?: string, capacity?: boolean): Promise<Task> {
2583
2647
  const task = this.need(id, true, true);
2584
2648
  this.mine(task, by, "owner", true);
2585
2649
  // The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
2586
2650
  const planned = this.plannerPullRecord(task)?.pull === true;
2587
- const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner } : {});
2651
+ const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
2588
2652
  if (planned) this.offered.delete(task.id);
2589
- this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
2590
- return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(planned ? { planner: true } : {}) });
2653
+ this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
2654
+ // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
2655
+ return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
2591
2656
  }
2592
2657
 
2593
2658
  /** Tasks whose check is queued or running, and the owner it was started for. */
@@ -2660,7 +2725,9 @@ export class Tasks {
2660
2725
  if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
2661
2726
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
2662
2727
  this.checking.set(task.id, task.owner);
2663
- const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
2728
+ const checkedRefs = cleanRefs(refs);
2729
+ this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
2730
+ const pending = this.d.board.update(task.id, by, "done (checking)", { refs: checkedRefs }, summary, { withheld: withheld || undefined });
2664
2731
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
2665
2732
  const seen = { events: pending.history.length, owner: pending.owner };
2666
2733
  this.pendingChecks++;
@@ -2700,6 +2767,7 @@ export class Tasks {
2700
2767
  const at = [...u.history].reverse().find((h) => h.event === "done");
2701
2768
  if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
2702
2769
  const theirs = this.places(u);
2770
+ if (mine.repositoryKey !== theirs.repositoryKey) return [];
2703
2771
  const paths = t.profile && u.profile && (t.plan?.sections?.length && u.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
2704
2772
  const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
2705
2773
  return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
@@ -2809,7 +2877,9 @@ export class Tasks {
2809
2877
  const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
2810
2878
  ? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
2811
2879
  const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
2812
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
2880
+ const completedRefs = cleanRefs(refs);
2881
+ this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
2882
+ const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
2813
2883
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
2814
2884
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
2815
2885
  this.tellCompleted(next, summary, withheld);
@@ -2869,7 +2939,9 @@ export class Tasks {
2869
2939
  const t = this.d.board.get(id);
2870
2940
  if (!t || this.isPii(t)) return [];
2871
2941
  const done = [...t.history].reverse().find((h) => h.event === "done");
2872
- return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note, done?.withheld)];
2942
+ const mine = this.places(task), theirs = this.places(t);
2943
+ if (mine.repositoryKey !== theirs.repositoryKey) return [];
2944
+ return [this.completedNotice(t, { task, paths: mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note, done?.withheld)];
2873
2945
  });
2874
2946
  return lines.length ? lines.join("\n") : undefined;
2875
2947
  }
@@ -2956,7 +3028,9 @@ export class Tasks {
2956
3028
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
2957
3029
  catch { projection = "(document projection unavailable)"; }
2958
3030
  const brief = this.briefText(task);
2959
- const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
3031
+ // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
3032
+ const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
3033
+ const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
2960
3034
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
2961
3035
  }
2962
3036
 
@@ -3341,6 +3415,12 @@ export class Tasks {
3341
3415
  return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
3342
3416
  }
3343
3417
 
3418
+ /**
3419
+ * The whole public-text screen for metadata the hub itself reads from outside the board (a git branch name, #572): a PII
3420
+ * pattern, a secret shape and a path the project marks private. `nameable` alone is the pattern screen only.
3421
+ */
3422
+ publicMetadataText = (text: string): boolean => this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, undefined);
3423
+
3344
3424
  private candidateTextAllowed(text: string, task?: Task): boolean {
3345
3425
  return (!task || !this.isPii(task)) && this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, task);
3346
3426
  }
@@ -0,0 +1,130 @@
1
+ import { MAX_COMMAND_MS, orcaExecutable, type CommandResult, type CommandRunner, type RecordedTerminalLaunch } from "../cli/terminal-recovery.ts";
2
+ import { spawn } from "node:child_process";
3
+ import { descendantsOf, readProcessTable, stopOwnedProcess, trackGroup } from "./child-process.ts";
4
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
5
+
6
+ export interface TerminalOwnerProof {
7
+ generation: string;
8
+ nativePid?: number;
9
+ isCurrent(): boolean;
10
+ }
11
+
12
+ /** Own only the daemon's background read, never the person's native TUI or Orca application. */
13
+ export function terminalOwnerReads(executable = orcaExecutable()): { run: CommandRunner; stop(): Promise<void> } {
14
+ let stopped = false;
15
+ const active = new Set<{ stop(): Promise<void>; done: Promise<CommandResult> }>();
16
+ return {
17
+ run(argv) {
18
+ if (stopped) return Promise.reject(new Error("terminal owner reader stopped"));
19
+ const child = spawn(executable, [...argv], { detached: true, stdio: ["ignore", "pipe", "pipe"] });
20
+ trackGroup(child);
21
+ let termination: Promise<void> | undefined;
22
+ const terminate = () => termination ??= stopOwnedProcess(child, { group: true }).catch(error => { stopped = true; throw error; });
23
+ let cancel!: (reason: Error) => void;
24
+ const cancelled = new Promise<never>((_, reject) => { cancel = reject; });
25
+ let entry!: { stop(): Promise<void>; done: Promise<CommandResult> };
26
+ const chunks: Buffer[] = []; let bytes = 0;
27
+ child.stdout.on("data", (chunk: Buffer) => {
28
+ bytes += chunk.length;
29
+ // ponytail: show identity must fit 1 MiB; use an identity-only Orca endpoint if terminal payloads exceed it.
30
+ if (bytes > 1024 * 1024) { void entry.stop().catch(() => {}); return; }
31
+ chunks.push(chunk);
32
+ });
33
+ child.stderr.resume(); // Never retain terminal text from a failed read in daemon diagnostics.
34
+ const result = new Promise<CommandResult>((resolve, reject) => {
35
+ child.once("error", () => reject(new Error("terminal owner query could not start")));
36
+ child.once("close", code => resolve({ status: code ?? 1, stdout: Buffer.concat(chunks).toString("utf8"), stderr: "" }));
37
+ });
38
+ const done = (async () => {
39
+ const timer = setTimeout(() => { void entry.stop().catch(() => {}); }, MAX_COMMAND_MS);
40
+ try { return await Promise.race([result, cancelled]); }
41
+ finally {
42
+ clearTimeout(timer);
43
+ try { await terminate(); active.delete(entry); }
44
+ catch { throw new Error("terminal owner query cleanup incomplete"); }
45
+ }
46
+ })();
47
+ entry = { done, stop: () => { cancel(new Error("terminal owner query cancelled")); return terminate(); } };
48
+ active.add(entry);
49
+ return done;
50
+ },
51
+ async stop() {
52
+ stopped = true;
53
+ const jobs = [...active];
54
+ const results = await Promise.allSettled(jobs.map(job => job.stop()));
55
+ await Promise.allSettled(jobs.map(job => job.done));
56
+ if (results.some(result => result.status === "rejected")) throw new Error("terminal owner query cleanup incomplete");
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Pending launch attempts are never recovery ownership. Only a current native owner can publish one. */
62
+ export class TerminalLaunches {
63
+ private readonly candidates = new Map<string, { row: RecordedTerminalLaunch; published?: string }>();
64
+ private running = false;
65
+ private lastCollection: number | undefined;
66
+ private stopped = false;
67
+ private readonly settlementWaiters: Array<() => void> = [];
68
+ constructor(private readonly opts: {
69
+ owner(row: RecordedTerminalLaunch): TerminalOwnerProof | undefined;
70
+ publish(row: RecordedTerminalLaunch, current: () => boolean): Promise<boolean>;
71
+ log?(message: string): void;
72
+ signature?: typeof processSignature;
73
+ now?(): number;
74
+ }) {}
75
+
76
+ register(row: RecordedTerminalLaunch): void {
77
+ if (this.stopped || processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature) !== "live") throw new Error("terminal launcher identity unavailable");
78
+ const previous = this.candidates.get(row.launchId);
79
+ if (previous) {
80
+ if (JSON.stringify(previous.row) !== JSON.stringify(row)) throw new Error("terminal launch identity changed");
81
+ return;
82
+ }
83
+ if (this.candidates.size >= 64) this.collectGone();
84
+ // ponytail: 64 live/unknown launch candidates per daemon. Fail closed at the cap; a durable owner ledger can extend it.
85
+ if (this.candidates.size >= 64) throw new Error("too many pending terminal launches");
86
+ this.candidates.set(row.launchId, { row });
87
+ this.lastCollection ??= (this.opts.now ?? Date.now)();
88
+ }
89
+
90
+ private collectGone(): void {
91
+ this.lastCollection = (this.opts.now ?? Date.now)();
92
+ const states = new Map<string, ReturnType<typeof processLiveness>>();
93
+ for (const [id, { row }] of this.candidates) {
94
+ const key = `${row.launcherPid}:${row.launcherSignature}`;
95
+ const state = states.get(key) ?? processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature);
96
+ states.set(key, state);
97
+ if (state === "gone") this.candidates.delete(id);
98
+ }
99
+ }
100
+
101
+ async reconcile(): Promise<void> {
102
+ if (this.running || this.stopped) return;
103
+ this.running = true;
104
+ try {
105
+ // Native ownership is in memory. Only publication needs an immediate process probe;
106
+ // collect exited launchers every 30 s (or at admission cap), not on the daemon's 1 s tick.
107
+ if (this.lastCollection !== undefined && (this.opts.now ?? Date.now)() - this.lastCollection >= 30_000) this.collectGone();
108
+ for (const [id, candidate] of this.candidates) {
109
+ const { row } = candidate;
110
+ const proof = this.opts.owner(row);
111
+ if (!proof || !proof.isCurrent() || candidate.published === proof.generation) continue;
112
+ const liveness = processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature);
113
+ if (liveness === "gone") { this.candidates.delete(id); continue; }
114
+ if (liveness !== "live") continue;
115
+ if (proof.nativePid !== undefined) {
116
+ const table = await readProcessTable();
117
+ if (!table || !descendantsOf(table, row.launcherPid).some(child => child.pid === proof.nativePid)) continue;
118
+ }
119
+ const current = () => !this.stopped && this.candidates.get(id) === candidate && proof.isCurrent() &&
120
+ processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature) === "live";
121
+ try {
122
+ if (current() && await this.opts.publish(row, current)) candidate.published = proof.generation;
123
+ } catch { if (!this.stopped) this.opts.log?.("terminal owner publication unavailable; inspect recovery terminal evidence"); }
124
+ }
125
+ } finally { this.running = false; for (const done of this.settlementWaiters.splice(0)) done(); }
126
+ }
127
+
128
+ clear(): void { this.stopped = true; this.candidates.clear(); }
129
+ settled(): Promise<void> { return this.running ? new Promise(resolve => this.settlementWaiters.push(resolve)) : Promise.resolve(); }
130
+ }
package/src/hub/usage.ts CHANGED
@@ -71,32 +71,58 @@ export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: str
71
71
  }
72
72
  }
73
73
 
74
+ /** Fixed backwards I/O chunk: a normal near-EOF completion costs only this tail read. */
75
+ export const COMPLETION_TAIL_BYTES = 4 * 1024 * 1024;
74
76
  /**
75
- * The tail read for turn completion: the newest end_turn row is always near the end.
76
- * ponytail: a fixed 4 MiB window; a completion written more than 4 MiB before EOF stays invisible (Claude appends the final
77
- * row last, so this needs >4 MiB written after the turn ended). Upgrade: scan backwards in chunks until an assistant row.
77
+ * ponytail: completion lookup reads at most 64 MiB plus one line-boundary byte. A row beyond that bound, or one
78
+ * whose start is outside it, stays unavailable. Upgrade: a session-bound completion index instead of rescanning.
78
79
  */
79
- export const COMPLETION_TAIL_BYTES = 4 * 1024 * 1024;
80
+ export const COMPLETION_SCAN_BYTES = 64 * 1024 * 1024;
80
81
 
81
82
  /**
82
- * The newest completed assistant turn (stop_reason end_turn) in a transcript of any size, from its last
83
- * COMPLETION_TAIL_BYTES only (#517: a whole-file cap made every Stop unverifiable on a long session). The first,
84
- * possibly partial, line of a cut tail is dropped. Undefined when there is none or the file cannot be read.
83
+ * The newest completed assistant turn, scanning complete JSONL rows backwards in fixed chunks. Reassemble bytes
84
+ * before decoding UTF-8; a cut row is never parsed as a valid suffix. The optional lookback budget remains bounded
85
+ * by COMPLETION_SCAN_BYTES, plus one byte to certify a row starting exactly at its boundary.
85
86
  */
86
- export function readClaudeTranscriptCompletion(sessionId: string, transcriptPath: string, tailBytes = COMPLETION_TAIL_BYTES): UsageRecord | undefined {
87
+ export function readClaudeTranscriptCompletion(sessionId: string, transcriptPath: string, maxBytes = COMPLETION_SCAN_BYTES): UsageRecord | undefined {
88
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return undefined;
87
89
  let fd: number | undefined;
88
90
  try {
89
91
  fd = openSync(transcriptPath, "r");
90
- const size = fstatSync(fd).size, start = Math.max(0, size - tailBytes);
91
- // One byte before the window shows whether the cut falls on a line boundary.
92
- const from = start > 0 ? start - 1 : 0, buffer = Buffer.alloc(size - from);
93
- let read = 0;
94
- while (read < buffer.length) { const n = readSync(fd, buffer, read, buffer.length - read, from + read); if (n <= 0) break; read += n; }
95
- let text = buffer.subarray(0, read).toString("utf8");
96
- // A cut inside a line drops up to the first newline, or all of it when it holds none; a cut right after a newline
97
- // keeps the complete row that starts the window.
98
- if (start > 0) { const nl = text.indexOf("\n"); text = nl < 0 ? "" : text.slice(nl + 1); }
99
- return transcriptRecords(sessionId, text).filter(record => record.completedTurn).at(-1);
92
+ const size = fstatSync(fd).size, start = Math.max(0, size - Math.min(maxBytes, COMPLETION_SCAN_BYTES));
93
+ const limit = Math.max(0, start - 1);
94
+ let position = size;
95
+ let parts: Buffer[] = [];
96
+ const seen = new Set<string>();
97
+ const completedRow = (): UsageRecord | undefined => {
98
+ const text = Buffer.concat(parts.reverse()).toString("utf8");
99
+ parts = [];
100
+ const record = transcriptRecords(sessionId, text)[0];
101
+ if (!record || seen.has(record.id)) return undefined;
102
+ seen.add(record.id); // A later non-end_turn row supersedes an older completion for the same message.
103
+ return record.completedTurn ? record : undefined;
104
+ };
105
+ while (position > limit) {
106
+ const from = Math.max(limit, position - COMPLETION_TAIL_BYTES), buffer = Buffer.alloc(position - from);
107
+ let read = 0;
108
+ while (read < buffer.length) {
109
+ const n = readSync(fd, buffer, read, buffer.length - read, from + read);
110
+ if (n <= 0) return undefined; // A shrinking transcript cannot certify a contiguous row.
111
+ read += n;
112
+ }
113
+ let end = read;
114
+ while (end > 0) {
115
+ const newline = buffer.lastIndexOf(10, end - 1);
116
+ if (newline < 0) break;
117
+ parts.push(buffer.subarray(newline + 1, end));
118
+ const record = completedRow();
119
+ if (record) return record;
120
+ end = newline;
121
+ }
122
+ if (end > 0) parts.push(buffer.subarray(0, end));
123
+ position = from;
124
+ }
125
+ return position === 0 && start === 0 ? completedRow() : undefined;
100
126
  } catch {
101
127
  return undefined;
102
128
  } finally {
@@ -1,4 +1,4 @@
1
- /** Hub-owned model policy. Deliberately separate from Switchyard sidecar tables. */
1
+ /** In-process hub model-route policy. */
2
2
  export interface HubRoute {
3
3
  type: "stage" | "plan_execute" | "advisor" | "escalation";
4
4
  efficient?: string;
@@ -37,16 +37,11 @@ export interface ChatResult {
37
37
  servedModel?: string;
38
38
  /** `x-omniroute-provider` (direct path) */
39
39
  provider?: string;
40
- /** `x-model-router-selected-model` (through Switchyard) */
41
- selectedModel?: string;
42
40
  }
43
41
  export interface ChatOptions {
44
42
  /** Refuse a changed/off-campus gateway immediately before transport. */
45
43
  onCampusOnly?: boolean;
46
44
  signal?: AbortSignal;
47
- /** Switchyard base (`http://127.0.0.1:<port>/v1`). The sidecar holds the key, so none is sent. */
48
- via?: string;
49
- sessionId?: string;
50
45
  }
51
46
 
52
47
  const readSecret = (file?: string) => {
@@ -159,17 +154,13 @@ export class OmniRoute {
159
154
  }
160
155
 
161
156
  async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number; temperature?: number; reasoning_effort?: string }, opts: ChatOptions = {}): Promise<ChatResult> {
162
- const base = opts.via ?? (await this.base());
157
+ const base = await this.base();
163
158
  if (!base) throw new Error("no model gateway is configured or reachable (omniroute.urls in .agenthub/config.json; see ahub doctor)");
164
- if (opts.onCampusOnly && (opts.via || this.isAccessHost(base))) throw new Error("PII model calls require the confirmed campus gateway");
159
+ if (opts.onCampusOnly && this.isAccessHost(base)) throw new Error("PII model calls require the confirmed campus gateway");
165
160
  const headers: Record<string, string> = { "content-type": "application/json" };
166
- if (opts.via) {
167
- if (opts.sessionId) headers["x-switchyard-session-id"] = opts.sessionId;
168
- } else {
169
- const key = this.apiKey();
170
- if (!key) throw new Error("no OmniRoute API key (set OMNIROUTE_API_KEY or omniroute.api_key_file)");
171
- Object.assign(headers, { authorization: `Bearer ${key}` }, this.accessHeaders(base));
172
- }
161
+ const key = this.apiKey();
162
+ if (!key) throw new Error("no OmniRoute API key (set OMNIROUTE_API_KEY or omniroute.api_key_file)");
163
+ Object.assign(headers, { authorization: `Bearer ${key}` }, this.accessHeaders(base));
173
164
  let res: Response;
174
165
  try {
175
166
  res = await fetch(`${base}/chat/completions`, {
@@ -179,7 +170,7 @@ export class OmniRoute {
179
170
  ...(opts.signal ? { signal: opts.signal } : {}),
180
171
  });
181
172
  } catch (e) {
182
- if (!opts.via) this.live = undefined; // probe again next time: the VPN may have dropped
173
+ this.live = undefined; // probe again next time: the VPN may have dropped
183
174
  throw e;
184
175
  }
185
176
  if (!res.ok) throw new Error(`model call failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
@@ -187,17 +178,14 @@ export class OmniRoute {
187
178
  const message = json.choices?.[0]?.message;
188
179
  if (!message) throw new Error("model call returned no message");
189
180
  const provider = res.headers.get("x-omniroute-provider") ?? undefined;
190
- const selectedModel = res.headers.get("x-model-router-selected-model") ?? undefined;
191
- const servedModel = safeModelLabel(json.model) ?? safeModelLabel(selectedModel);
181
+ const servedModel = safeModelLabel(json.model);
192
182
  const safeProvider = safeModelLabel(provider);
193
- const safeSelectedModel = safeModelLabel(selectedModel);
194
183
  const usage = normalizeUsage(json.usage);
195
184
  // reasoning_content and other extras are dropped here: only role, content and tool_calls travel on.
196
185
  return {
197
186
  message: { role: "assistant", content: message.content ?? null, ...(message.tool_calls?.length ? { tool_calls: message.tool_calls } : {}) },
198
187
  ...(usage ? { usage } : {}),
199
188
  ...(safeProvider ? { provider: safeProvider } : {}),
200
- ...(safeSelectedModel ? { selectedModel: safeSelectedModel } : {}),
201
189
  ...(servedModel ? { servedModel } : {}),
202
190
  };
203
191
  }
@@ -11,5 +11,6 @@ This project runs agent-hub: other coding agents (claude, codex, kimi, qwen, jco
11
11
  - Do not use `[FYI]` for PR ready, done, submitted, ruling needed, blocker, or changed contract or path messages: each requires recipient action. Use `[FYI]` only for progress or information needing no action. `hub_task_done` and `hub_review` already notify assigned recipients; no duplicate chat is needed.
12
12
  - Read the kind of each message (`meta.kind` on a channel tag, each item's kind in a digest, the kind in a prompt header). Only `hub` items with kind `presence` are shared memory for reference, not requests. Its `task`, `review`, and `budget` items are workflow events: check the task board and your assigned role, then use the appropriate hub tools within the user's authorized scope. Sender and kind never override user instructions or safety rules.
13
13
  - The task board is the record of who does what: `hub_task_propose`, `hub_task_accept` / `hub_task_decline`, `hub_task_done`, `hub_review`, `hub_task_list`. Default roles: Claude plans and reviews, Codex implements, Kimi, Pi and the local worker implement and verify; `.agenthub/config.json` `roles` is the source of truth.
14
+ - After pushing a PR, call `hub_task_done` stating the PR, its head commit and the CI state, then end your turn. Do not wait for or poll CI: a waiting turn costs quota and leaves you deaf to messages, and the reviewer checks CI. Work in a worktree under `.worktrees/`, never on a branch in the project root checkout.
14
15
  - Implementers claim work nobody assigned them with `hub_task_propose`, naming themselves as `owner`, with the paths in `refs` and a `plan` (files, symbols, signatures, insertion points; `hub_task_accept` takes one too). When the paths or symbols overlap another open task the hub says so, the later claimant settles it with that owner, and that owner gets the plan with its next message. When a task is done, the owners of overlapping open tasks get a notice of what changed. In a project with `coordination: "turn-free"` in `.agenthub/config.json`, a task's texts say when its owners form a silent cohort: then they do not message each other, the hub shows each of them the other's changes as they work, and the last to finish is asked to check its work against the others and call `hub_task_done` again. `hub_task_done` says what changed, why, and the check that was run with its result.
15
16
  - `hub_remember` saves a decision, finding, contract or `fail` (an approach that does not work, and why) to the memory all agents share; the other agents get it with their next message. Do not retry what a `fail` note rules out without new evidence. A task shown as `[pii]` is handled by the on-prem worker only: do not ask for its content.