@staix/agent-hub 0.12.32 → 0.12.34

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 (110) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +6 -5
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +5 -3
  5. package/docs/agent-notes/bus.md +2 -0
  6. package/docs/agent-notes/daemon.md +7 -5
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/agent-notes/tests.md +2 -1
  9. package/docs/operations.md +48 -14
  10. package/docs/quickstart.md +5 -3
  11. package/docs/security.md +10 -2
  12. package/docs/smoke.md +1 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
  14. package/docs/specs/2026-09-20-upgrade-recovery-design.md +20 -0
  15. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  16. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  17. package/docs/verified.json +33 -19
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +22882 -16496
  21. package/src/adapters/codex-appserver.ts +81 -11
  22. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  23. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  24. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  25. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  26. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  27. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  28. package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
  31. package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
  32. package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
  33. package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
  34. package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
  35. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  36. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  37. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  38. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  39. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  40. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  41. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  43. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  44. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  46. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  47. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  48. package/src/adapters/definitions/native-config-policy.ts +35 -0
  49. package/src/adapters/definitions/native-context.ts +35 -0
  50. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  51. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  52. package/src/adapters/definitions/native-display.ts +18 -0
  53. package/src/adapters/definitions/native-factories.ts +48 -0
  54. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  55. package/src/adapters/definitions/native-quota.ts +29 -0
  56. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  57. package/src/adapters/definitions/native-recovery.ts +88 -0
  58. package/src/adapters/definitions/qwen.ts +8 -4
  59. package/src/adapters/registry-types.ts +97 -10
  60. package/src/adapters/registry.ts +103 -3
  61. package/src/cli/console-state.ts +5 -5
  62. package/src/cli/identity.ts +6 -5
  63. package/src/cli/jcode-mcp.ts +2 -58
  64. package/src/cli/launch.ts +14 -100
  65. package/src/cli/main.ts +35 -272
  66. package/src/cli/output.ts +3 -2
  67. package/src/cli/preview.ts +14 -125
  68. package/src/cli/recovery-guidance.ts +17 -13
  69. package/src/cli/reset.ts +13 -1
  70. package/src/cli/setup.ts +2 -80
  71. package/src/cli/shell-quote.ts +3 -0
  72. package/src/cli/terminal-recovery.ts +17 -10
  73. package/src/cli/upgrade-interactive.ts +4 -2
  74. package/src/cli/upgrade-presentation.ts +11 -1
  75. package/src/cli/upgrade-restore-state.ts +2 -1
  76. package/src/cli/upgrade-runtime.ts +107 -146
  77. package/src/cli/upgrade.ts +10 -7
  78. package/src/hub/budget.ts +6 -30
  79. package/src/hub/bus.ts +133 -14
  80. package/src/hub/conductor.ts +8 -4
  81. package/src/hub/config-apply.ts +9 -13
  82. package/src/hub/config-reload.ts +7 -2
  83. package/src/hub/context-window.ts +3 -27
  84. package/src/hub/control-client.ts +1 -1
  85. package/src/hub/crash.ts +2 -35
  86. package/src/hub/daemon.ts +244 -241
  87. package/src/hub/deferred-hub.ts +60 -0
  88. package/src/hub/delivery-journal.ts +6 -0
  89. package/src/hub/execution-budget.ts +2 -1
  90. package/src/hub/hub-tools.ts +3 -3
  91. package/src/hub/peer-catalog.ts +4 -13
  92. package/src/hub/permission-mode.ts +9 -12
  93. package/src/hub/recovery-continuity.ts +6 -5
  94. package/src/hub/report.ts +9 -8
  95. package/src/hub/restart.ts +12 -1
  96. package/src/hub/roster.ts +6 -7
  97. package/src/hub/routing.ts +21 -10
  98. package/src/hub/settings.ts +6 -5
  99. package/src/hub/settled-roles.ts +1 -1
  100. package/src/hub/start-mode.ts +8 -6
  101. package/src/hub/tasks.ts +14 -11
  102. package/src/hub/tooling.ts +4 -60
  103. package/src/hub/ui.ts +3 -1
  104. package/src/memory/recall.ts +4 -5
  105. package/src/models/route/signals.ts +3 -1
  106. package/src/pi/launch.ts +2 -1
  107. package/src/ui/index.html +3 -2
  108. package/src/ui/office-model.ts +3 -1
  109. package/src/ui/peer-palette.ts +16 -6
  110. package/templates/routing.toml +2 -2
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
2
3
  import { checkedSessionPath } from "./recovery-session-file.ts";
3
4
  import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
@@ -32,6 +33,8 @@ export interface Inspection {
32
33
  blockers: string[];
33
34
  /** #215: for a stopped runtime, the operation whose committed restart snapshot its state dir holds (unreleased). */
34
35
  snapshot?: string;
36
+ /** The runtime may be positively stopped while its restart snapshot remains unreadable; never claim ownership. */
37
+ snapshotError?: { code: "recovery_deferred_hub"; message: string };
35
38
  }
36
39
  export interface PlannedProject {
37
40
  project: Project;
@@ -335,7 +338,7 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
335
338
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
336
339
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
337
340
  return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
338
- .filter((peer) => peer !== "pi" && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
341
+ .filter((peer) => definitionOf(peer)?.session.personFresh === true && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
339
342
  }
340
343
 
341
344
  /** Read-only scope classification; it confers no delegation authority. */
@@ -361,7 +364,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
361
364
  ? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
362
365
  : (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
363
366
  const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
364
- return !!binding && (peer === "codex" ? current?.threadId : current?.sessionId) === binding.sessionId;
367
+ return !!binding && current?.[definitionOf(peer)?.session.idField ?? "sessionId"] === binding.sessionId;
365
368
  }
366
369
 
367
370
  /** Registry reads for planning must not create a registry or run migrations. */
@@ -442,7 +445,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
442
445
  if (progress.reattachments === undefined) continue;
443
446
  if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
444
447
  for (const [peer, marker] of Object.entries(progress.reattachments)) {
445
- if (!["codex", "pi", "claude"].includes(peer) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
448
+ if (definitionOf(peer)?.session.launchRecord !== true || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
446
449
  !["ready", "attempted"].includes(String(marker.state)) || marker.cause !== "detached after restore" || typeof marker.at !== "number" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid reattachment marker");
447
450
  const receipt = decodeRestoreReceipt({ state: "done", binding: marker.binding });
448
451
  if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== op.plan.projects[index]?.project.root) throw new Error("reattachment binding does not match its peer and project");
@@ -464,7 +467,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
464
467
  }
465
468
  if (op.blockedReattachment !== undefined) {
466
469
  const selected = op.blockedReattachment;
467
- if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || !["codex", "pi", "claude"].includes(String(selected.peer)) ||
470
+ if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || definitionOf(String(selected.peer))?.session.launchRecord !== true ||
468
471
  !op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
469
472
  }
470
473
  }
@@ -792,7 +795,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
792
795
  let op = readOperation<RecoveryOperation>(id, home);
793
796
  validateReceipt(id, op);
794
797
  const selection = () => {
795
- if (!["codex", "pi", "claude"].includes(peer) || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
798
+ if (definitionOf(peer)?.session.launchRecord !== true || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
796
799
  const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
797
800
  const planned = op.plan.projects.find(project => project.project.id === progress?.id);
798
801
  const native = peer as RecoveryNativePeer;
@@ -817,7 +820,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
817
820
  };
818
821
  await target();
819
822
  attempted = true;
820
- await driver.restore(planned, progress, op, native === "claude" ? "claude" : "native", save, native);
823
+ await driver.restore(planned, progress, op, definitionOf(native)?.transport === "claude-channel" ? "claude" : "native", save, native);
821
824
  // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
822
825
  // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
823
826
  const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
@@ -913,7 +916,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
913
916
  const next = nextActionsText(op, live);
914
917
  if ("fresh" in choice) {
915
918
  // `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
916
- if (choice.fresh === "pi") throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP))).join("; ")}; ${next}`);
919
+ if (definitionOf(choice.fresh)?.session.personFresh === false) throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP))).join("; ")}; ${next}`);
917
920
  if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
918
921
  if (!targetReadsWaivers(op)) throw new Error(`--fresh-session is not available: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session; ${next}`);
919
922
  // The new session is launched by resume, so it helps only where resume can get past what is live.
package/src/hub/budget.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import type { PeerId } from "./envelope.ts";
3
4
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -70,8 +71,6 @@ const WAIT_NOTE = "; keeps its work:";
70
71
  const WAITED_NOTE = "; waited, then the reset moved to";
71
72
  const waitedNote = (min: number, max: number) => `${WAITED_NOTE} ${min} min, beyond wait_max_min ${max}`;
72
73
  const FALLBACK_WINDOW_MINS = 300;
73
- /** Peers the coordinator never pauses: the local worker has no quota. */
74
- const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
75
74
 
76
75
  /**
77
76
  * Budget relay. Readings come from the adapters (Codex rate limits, Claude's status line, Kimi tokens) or from
@@ -156,7 +155,10 @@ export class Budget {
156
155
  /** `hard`: the peer cannot run another turn (its provider refused one), so there is nobody to ask for a checkpoint. */
157
156
  /** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
158
157
  report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
159
- if (EXEMPT.has(peer) || this.closed) return;
158
+ // Execution-budget instrumentation is independent of native quota accounting.
159
+ // Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
160
+ const adapter = definitionOf(peer);
161
+ if (peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
160
162
  // An override ignores soft readings, not a provider that has actually refused another turn.
161
163
  if (hard && this.ignoreUntil.delete(peer)) {
162
164
  try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
@@ -356,30 +358,4 @@ export class Budget {
356
358
  }
357
359
  }
358
360
 
359
- /** Codex `rateLimits` snapshot (account/rateLimits/read, account/rateLimits/updated) -> windows. */
360
- export function codexWindows(rateLimits: any): UsageWindow[] {
361
- const out: UsageWindow[] = [];
362
- // The slot decides (primary = short window, secondary = long one) and the duration overrides it when present:
363
- // readings are keyed by window id, so two windows must never both come out as "5h".
364
- for (const [w, slot] of [[rateLimits?.primary, "5h"], [rateLimits?.secondary, "week"]] as const) {
365
- if (!w || typeof w.usedPercent !== "number") continue;
366
- const mins = typeof w.windowDurationMins === "number" ? w.windowDurationMins : undefined;
367
- const id = mins === undefined ? slot : mins > 600 ? "week" : "5h";
368
- if (out.some((o) => o.id === id)) continue; // both slots claim the same window: keep the first, never overwrite
369
- out.push({ id, used: w.usedPercent / 100, ...(w.resetsAt ? { resetsAt: w.resetsAt * 1000 } : {}), ...(mins ? { windowMins: mins } : {}), source: "codex rateLimits" });
370
- }
371
- if (rateLimits?.rateLimitReachedType && !out.some((w) => w.used >= 1)) out.push({ id: "5h", used: 1, source: `codex ${rateLimits.rateLimitReachedType}` });
372
- return out;
373
- }
374
-
375
- /** Claude Code status line `rate_limits` -> windows. */
376
- export function claudeWindows(rateLimits: any): UsageWindow[] {
377
- const out: UsageWindow[] = [];
378
- for (const [key, id, mins] of [["five_hour", "5h", 300], ["seven_day", "week", 10_080]] as const) {
379
- const w = rateLimits?.[key];
380
- if (!w || typeof w.used_percentage !== "number") continue;
381
- const resets = Number(w.resets_at);
382
- out.push({ id, used: w.used_percentage / 100, ...(resets ? { resetsAt: resets < 1e12 ? resets * 1000 : resets } : {}), windowMins: mins, source: "claude status line" });
383
- }
384
- return out;
385
- }
361
+ export { codexWindows, claudeWindows } from "../adapters/definitions/native-quota.ts";
package/src/hub/bus.ts CHANGED
@@ -2,6 +2,7 @@ import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
4
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
5
+ import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
5
6
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
6
7
 
7
8
  export type { DeliveryReceipt } from "./peers.ts";
@@ -64,6 +65,8 @@ export interface BusSnapshot {
64
65
  attempts: Record<string, number>;
65
66
  withdrawn: string[];
66
67
  manualPaused?: string[];
68
+ /** Hub notices accepted during recovery, durable but outside the carried delivery queues. */
69
+ deferredHub?: Envelope[];
67
70
  journal?: ReturnType<DeliveryJournal["snapshot"]>;
68
71
  }
69
72
 
@@ -142,6 +145,12 @@ export class Bus {
142
145
  /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
143
146
  onDeliveryFailed?: (peer: PeerId) => void;
144
147
  private recoveryHeld = false;
148
+ private deferHub = false;
149
+ private readonly deferredHub = new Map<string, Envelope>();
150
+ private flushingHub = false;
151
+ private replayingHub = false;
152
+ private deferredEvents: BusEvent[] = [];
153
+ private deferredNudges: (() => void)[] = [];
145
154
  private steering = 0;
146
155
  private condensing = 0;
147
156
  private closed = false;
@@ -273,10 +282,61 @@ export class Bus {
273
282
  }
274
283
 
275
284
  /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
276
- setRecoveryHold(held: boolean): void {
277
- this.recoveryHeld = held;
278
- if (!held) this.onQueues?.(); // restored holds need classification even when no peer is attached
279
- if (!held) for (const id of this.peers.keys()) void this.drain(id);
285
+ setRecoveryHold(held: boolean, deferHub = false): void {
286
+ if (this.replayingHub) throw new RecoveryDeferredHubError("recovery notice release is already in progress");
287
+ if (held) {
288
+ this.recoveryHeld = true;
289
+ this.deferHub = deferHub;
290
+ // An older target cannot carry this additive field: materialize before commit's expected integrity.
291
+ if (!deferHub) this.flushDeferredHub();
292
+ return;
293
+ }
294
+ // Persist each deferred -> queued transition while deliveries are still fenced. A failed checkpoint
295
+ // leaves the hold and the original pending head intact; a crash after it resumes from the journal.
296
+ this.flushDeferredHub();
297
+ this.recoveryHeld = false;
298
+ this.onQueues?.();
299
+ for (const id of this.peers.keys()) void this.drain(id);
300
+ }
301
+
302
+ private flushDeferredHub(): void {
303
+ if (this.flushingHub) return;
304
+ if (this.storageError) this.persist();
305
+ this.replayingHub = true;
306
+ try { while (this.deferredHub.size) {
307
+ const env = this.deferredHub.values().next().value!;
308
+ const beforeDeferred = [...this.deferredHub], beforeSeen = [...this.seen], beforeWithdrawn = [...this.withdrawn];
309
+ const affected = new Map(this.audience(env).map(peer => {
310
+ const queue = this.queues.get(peer);
311
+ return [peer, queue ? { queue, contents: [...queue] } : undefined] as const;
312
+ }));
313
+ const outcomes = this.pendingOutcomes.length;
314
+ this.flushingHub = true;
315
+ this.deferredEvents = []; this.deferredNudges = [];
316
+ try {
317
+ this.deferredHub.delete(env.id);
318
+ this.publishNow(env);
319
+ } catch (error) {
320
+ // Only publication state is transactional. A live peer's preface, pauses, attempts and
321
+ // recovery mode are independent state, not something a failed checkpoint may reload.
322
+ this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
323
+ this.seen.clear(); for (const [id, seen] of beforeSeen) this.seen.set(id, seen);
324
+ this.withdrawn.clear(); for (const id of beforeWithdrawn) this.withdrawn.add(id);
325
+ for (const [peer, queue] of affected) {
326
+ if (queue) { queue.queue.splice(0, queue.queue.length, ...queue.contents); this.queues.set(peer, queue.queue); }
327
+ else this.queues.delete(peer);
328
+ }
329
+ this.pendingOutcomes.length = outcomes;
330
+ this.deferredEvents = []; this.deferredNudges = [];
331
+ throw new RecoveryDeferredHubError("deferred hub notice checkpoint failed; recovery remains held; restore delivery storage, then retry release or abort");
332
+ } finally { this.flushingHub = false; }
333
+ // Publication observers run only after the durable transition. Reentrant notices join the FIFO tail.
334
+ const events = this.deferredEvents; this.deferredEvents = [];
335
+ for (const event of events) this.emit(event);
336
+ const nudges = this.deferredNudges; this.deferredNudges = [];
337
+ for (const nudge of nudges) nudge();
338
+ this.onQueues?.();
339
+ } } finally { this.replayingHub = false; }
280
340
  }
281
341
 
282
342
  get isRecoveryHeld(): boolean {
@@ -296,7 +356,7 @@ export class Bus {
296
356
  let delay = 1;
297
357
  while (this.draining.size || this.steering || this.condensing) {
298
358
  if (Date.now() >= deadline) {
299
- this.recoveryHeld = heldBefore;
359
+ if (!heldBefore) this.setRecoveryHold(false);
300
360
  throw new Error(`recovery fence timed out after ${timeoutMs} ms with ${this.draining.size + this.steering + this.condensing} deliveries still in flight; retry the operation`);
301
361
  }
302
362
  await Bun.sleep(delay);
@@ -314,6 +374,7 @@ export class Bus {
314
374
  attempts: Object.fromEntries(this.attempts),
315
375
  withdrawn: [...this.withdrawn],
316
376
  manualPaused: [...this.manualPaused],
377
+ ...(this.deferredHub.size ? { deferredHub: structuredClone([...this.deferredHub.values()]) } : {}),
317
378
  };
318
379
  if (includeJournal && this.journal) out.journal = this.journal.snapshot();
319
380
  return out;
@@ -341,6 +402,10 @@ export class Bus {
341
402
  }
342
403
 
343
404
  private loadSnapshot(snapshot: BusSnapshot): void {
405
+ const deferred = validateDeferredHub(snapshot.deferredHub, undefined, new Set([...snapshot.seen.map(e => e.id), ...Object.values(snapshot.queues).flat().map(e => e.id)]));
406
+ if (deferred.length) { this.recoveryHeld = true; this.deferHub = true; }
407
+ this.deferredHub.clear();
408
+ for (const env of deferred) this.deferredHub.set(env.id, structuredClone(env));
344
409
  this.queues.clear();
345
410
  for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
346
411
  this.prefaces.clear();
@@ -532,7 +597,7 @@ export class Bus {
532
597
 
533
598
  queuedBacklogIds(id: PeerId): Set<number> {
534
599
  const ids = new Set<number>();
535
- const retained = [...(this.queues.get(id) ?? []), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
600
+ const retained = [...this.pendingEnvelopes(id), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
536
601
  for (const env of retained) {
537
602
  const backlog = env.refs?.backlog;
538
603
  if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
@@ -602,8 +667,52 @@ export class Bus {
602
667
  this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
603
668
  }
604
669
 
605
- /** Returns the peers the envelope was queued for. */
670
+ /** Stopped-runtime reset only: audit every deferred recipient without publishing or releasing work. */
671
+ discardDeferredHub(reason: string): string[] {
672
+ if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
673
+ const pending = [...this.deferredHub];
674
+ const settled: string[] = [];
675
+ try {
676
+ this.journal.transaction(() => {
677
+ for (const [, env] of pending) for (const peer of new Set(env.to ?? [])) {
678
+ const id = `q:${peer}:${env.id}`;
679
+ this.journal!.createDelivery({ id, peer, state: "queued", createdAt: env.ts, originals: [env], out: [env] });
680
+ const row = this.journal!.get(id)!;
681
+ this.journal!.resolve(id, row.revision, "discard", reason, row.revision, "user");
682
+ settled.push(id);
683
+ }
684
+ this.deferredHub.clear();
685
+ this.persist();
686
+ });
687
+ } catch (error) {
688
+ this.deferredHub.clear(); for (const [id, env] of pending) this.deferredHub.set(id, env);
689
+ throw error;
690
+ }
691
+ return settled;
692
+ }
693
+
694
+ /** Accepted pending work, including notices withheld from the recovery integrity view. */
695
+ pendingEnvelopes(peer: PeerId): Envelope[] {
696
+ return [...(this.queues.get(peer) ?? []), ...[...this.deferredHub.values()].filter(e => e.to?.includes(peer))];
697
+ }
698
+
699
+ /** Returns the peers the envelope was accepted for, including durable deferred notices. */
606
700
  publish(env: Envelope): PeerId[] {
701
+ if (this.storageError) throw new Error("delivery journal unavailable");
702
+ if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return [];
703
+ if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
704
+ if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
705
+ const targets = this.audience(env);
706
+ const copy = structuredClone({ ...env, to: targets });
707
+ this.deferredHub.set(copy.id, copy);
708
+ try { this.persist(); }
709
+ catch (error) { this.deferredHub.delete(copy.id); throw error; }
710
+ return targets;
711
+ }
712
+ return this.publishNow(env);
713
+ }
714
+
715
+ private publishNow(env: Envelope): PeerId[] {
607
716
  if (this.storageError) throw new Error("delivery journal unavailable");
608
717
  if (this.seen.has(env.id)) return [];
609
718
  this.seen.set(env.id, env);
@@ -638,7 +747,7 @@ export class Bus {
638
747
  } } finally { this.suppressDrain = false; }
639
748
  if (this.journal) {
640
749
  this.persist();
641
- this.onQueues?.();
750
+ if (!this.flushingHub) this.onQueues?.();
642
751
  for (const id of targets) {
643
752
  const peer = this.peers.get(id);
644
753
  if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
@@ -681,7 +790,7 @@ export class Bus {
681
790
 
682
791
  /** A recently published envelope, for resolving `reply_to`. */
683
792
  get(id: string): Envelope | undefined {
684
- return this.seen.get(id);
793
+ return this.seen.get(id) ?? this.deferredHub.get(id);
685
794
  }
686
795
 
687
796
  /** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
@@ -696,10 +805,10 @@ export class Bus {
696
805
  */
697
806
  withdraw(envelopeId: string): boolean {
698
807
  this.markWithdrawn(envelopeId);
699
- let removed = false;
808
+ let removed = this.deferredHub.delete(envelopeId);
700
809
  for (const queue of this.queues.values()) {
701
810
  const i = queue.findIndex((e) => e.id === envelopeId);
702
- if (i !== -1) removed = queue.splice(i, 1).length > 0;
811
+ if (i !== -1) { queue.splice(i, 1); removed = true; }
703
812
  }
704
813
  if (removed) this.onQueues?.();
705
814
  this.persist();
@@ -709,8 +818,15 @@ export class Bus {
709
818
  /** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
710
819
  revokeSupervision(peer: PeerId): number {
711
820
  const queue = this.queues.get(peer);
712
- if (!queue && !this.preparing.has(peer)) return 0;
713
821
  let removed = 0;
822
+ for (const [id, env] of this.deferredHub) {
823
+ if (!env.refs?.supervision || !env.to?.includes(peer)) continue;
824
+ const remaining = env.to.filter(target => target !== peer);
825
+ if (remaining.length) this.deferredHub.set(id, { ...env, to: remaining });
826
+ else this.deferredHub.delete(id);
827
+ removed++;
828
+ }
829
+ if (!queue && !this.preparing.has(peer)) { if (removed) this.persist(); return removed; }
714
830
  const discarded = new Set<string>();
715
831
  for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
716
832
  const env = queue![i]!;
@@ -796,9 +912,11 @@ export class Bus {
796
912
  }
797
913
  if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
798
914
  // Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
799
- try { this.opts.onImportantQueued?.(id, { queued: queue.length, important: queue.filter((e) => e.priority === "important").length }); } catch { /* a nudge never stops an enqueue */ }
915
+ const counts = { queued: queue.length, important: queue.filter((e) => e.priority === "important").length };
916
+ const nudge = () => { try { this.opts.onImportantQueued?.(id, counts); } catch { /* a nudge never stops an enqueue */ } };
917
+ if (this.flushingHub) this.deferredNudges.push(nudge); else nudge();
800
918
  }
801
- this.onQueues?.();
919
+ if (!this.flushingHub) this.onQueues?.();
802
920
  if (!this.suppressDrain) this.persist();
803
921
  if (!this.suppressDrain) void this.drain(id);
804
922
  }
@@ -1060,6 +1178,7 @@ export class Bus {
1060
1178
  }
1061
1179
 
1062
1180
  private emit(e: BusEvent): void {
1181
+ if (this.flushingHub) { this.deferredEvents.push(e); return; }
1063
1182
  for (const tap of this.taps) tap(e);
1064
1183
  }
1065
1184
  }
@@ -1,10 +1,11 @@
1
+ import { definitionOf, SUPPORTED_PEERS } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import type { Task } from "./board.ts";
3
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
5
  import { HUB, USER } from "./envelope.ts";
5
6
  import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
6
7
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
- import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
8
+ import type { StartMode } from "./start-mode.ts";
8
9
  import type { HubEvent } from "./events.ts";
9
10
  import type { SupervisionFeed } from "./supervision.ts";
10
11
  import type { Budget } from "./budget.ts";
@@ -103,6 +104,8 @@ export class ConductorHolds {
103
104
  close(): void { this.db.close(); }
104
105
  }
105
106
 
107
+ type StartablePeer = typeof SUPPORTED_PEERS[number];
108
+
106
109
  export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
107
110
  /**
108
111
  * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
@@ -111,10 +114,11 @@ export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer:
111
114
  export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
112
115
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
113
116
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
114
- if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
115
- const own = startMode(peer);
117
+ if (!(typeof peer === "string" && definitionOf(peer)?.start.conductorStartable)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
118
+ const selected = peer as StartablePeer;
119
+ const own = startMode(selected);
116
120
  if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
117
- return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
121
+ return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
118
122
  }
119
123
 
120
124
  const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
@@ -1,6 +1,6 @@
1
1
  import type { HubConfig } from "./daemon.ts";
2
2
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
3
- import { SUPPORTED_PEERS, PLANNER_PEERS } from "./peer-catalog.ts";
3
+ import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
4
4
  import { CLASSES } from "./board.ts";
5
5
  import { permissionDefaults } from "./permission-mode.ts";
6
6
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
@@ -103,7 +103,9 @@ const block = (schema: Record<string, Check>): Check => {
103
103
  };
104
104
  const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
105
105
  const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
106
+ const nativeValidators = { bool, text, strings, argv, number, words, block };
106
107
  const schema: Record<string, Check> = {
108
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
107
109
  watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
108
110
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
109
111
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
@@ -119,11 +121,9 @@ const schema: Record<string, Check> = {
119
121
  context: block({ gate: number(0, 1), stale_min: timerMinutes }),
120
122
  inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
121
123
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
122
- pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
123
124
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
124
125
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
125
- local: block({ deny: strings, bash_network: (v, k) => { if (v !== "direct") bool(v, k); }, network_allow: strings, max_steps: number(1, 1_000_000, true), read_allow: strings }),
126
- approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
126
+ approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
127
127
  task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
128
128
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
129
129
  if (value === undefined) continue;
@@ -132,7 +132,7 @@ const schema: Record<string, Check> = {
132
132
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
133
133
  } },
134
134
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
135
- peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), ["pi", "codex", "kimi"])(v, k); peerStartConfig(v); },
135
+ peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
136
136
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
137
137
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
138
138
  review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
@@ -145,23 +145,19 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
145
145
  validateRawConfig(raw);
146
146
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
147
147
  for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
148
- if (object(raw.mlx, "mlx").enabled === false && object(raw.pi, "pi").backend === "mlx") fail("pi.backend", "conflicts with disabled mlx");
148
+ for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
149
149
  }
150
150
 
151
151
  const restart: Record<string, string> = {
152
- "pi.dgx_coding": "Published Pi relay model catalogue requires a new daemon relay",
153
- "pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
154
- "routing.pi": "Pi relay context and model publication are bound to the running relay",
152
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
155
153
  memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
156
154
  omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
157
155
  checks: "Bounded runner and completion-check contracts are constructed together",
158
156
  pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
159
- "local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
160
- "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
161
157
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
162
158
  text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
163
159
  };
164
- const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
160
+ const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify"]);
165
161
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
166
162
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
167
163
  export function configApply(key: string): ApplyRule {
@@ -182,7 +178,7 @@ export function configApply(key: string): ApplyRule {
182
178
  else return fail(key, "unknown configuration key");
183
179
  }
184
180
  }
185
- if (top === "routing" && !key.startsWith("routing.pi") && key !== "routing") {
181
+ if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
186
182
  return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
187
183
  }
188
184
  if (nextMemory.has(key)) return { applies: "next use" };
@@ -1,3 +1,4 @@
1
+ import { ADAPTERS } from "../adapters/registry.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -66,15 +67,19 @@ function validateRoutingRaw(value: unknown): void {
66
67
  const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
67
68
  }
68
69
  };
70
+ const nativeValidators = { bool, text, strings, argv: strings,
71
+ number: (min = 1, max = Number.MAX_SAFE_INTEGER, _integer = true) => (v: unknown, k: string) => numeric(v, k, min, max),
72
+ words: (values: readonly string[]) => (v: unknown, k: string) => choice(v, k, values),
73
+ block: (fields: Record<string, (v: unknown, k: string) => void>) => (v: unknown, k: string) => closed(v, k, fields),
74
+ };
69
75
  closed(value, "routing", {
70
- local: (v, k) => closed(v, k, { route: text, fixed_model: text }),
76
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.routingSchema?.(nativeValidators) ?? {})),
71
77
  targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
72
78
  // parseRouting delegates these tables to the existing parseHubRoutes validator.
73
79
  hub_routes: (v, k) => { table(v, k); },
74
80
  stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
75
81
  signals: (v, k) => closed(v, k, { pii_patterns: strings, long_context_tokens: numeric, pii_screen: (x, p) => choice(x, p, ["off", "local"]) }),
76
82
  constraints: (v, k) => closed(v, k, { pii: (x, p) => choice(x, p, ["local_only", "off"]), long_context: (x, p) => choice(x, p, ["skip_local", "off"]), budget_paused: (x, p) => choice(x, p, ["skip_peer", "off"]) }),
77
- pi: (v, k) => closed(v, k, { dgx_max_context_tokens: numeric, mlx_max_context_tokens: numeric, efficient_wait_ms: (x, p) => numeric(x, p, 0, 119999) }),
78
83
  classes: (v, k) => { for (const [name, item] of Object.entries(table(v, k))) {
79
84
  if (!(CLASSES as readonly string[]).includes(name)) bad(`${k}.${name}`);
80
85
  closed(item, `${k}.${name}`, { peers, escalate_to: peers, route: text, fixed_model: text, local_allowed: bool, pi_backend: (x, p) => choice(x, p, ["dgx", "mlx"]) });
@@ -1,3 +1,6 @@
1
+ import { nonnegative, positive } from "../adapters/definitions/native-context.ts";
2
+ export { claudeContext, codexContext, acpContext } from "../adapters/definitions/native-context.ts";
3
+
1
4
  /** Native active-context occupancy, separate from accumulated billable usage (#185). */
2
5
  export interface ContextReading {
3
6
  source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
@@ -17,33 +20,6 @@ export interface ContextView {
17
20
  }
18
21
  export interface ContextConfig { gate: number; stale_min: number }
19
22
  export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
20
- const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
21
- const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
22
- const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
23
- const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
24
- export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
25
- const c = object(value);
26
- const current = object(c.current_usage);
27
- const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
28
- const window = positive(c.context_window_size) ? c.context_window_size : null;
29
- const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
30
- const tokens = nonnegative(sum) ? sum : null;
31
- const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
32
- return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
33
- }
34
- export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
35
- const c = object(value), last = object(c.last);
36
- const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
37
- const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
38
- return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
39
- }
40
- /** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
41
- export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
42
- const window = positive(usage.size) ? usage.size : null;
43
- const tokens = nonnegative(usage.used) ? usage.used : null;
44
- // If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
45
- return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
46
- }
47
23
  export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
48
24
  /** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
49
25
  export class ContextWindows {
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
8
8
 
9
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
10
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["task_cancel"] as const;
11
+ export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub"] as const;
12
12
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
13
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
14