mixdog 0.9.94 → 0.9.95

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 (161) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/lead/01-general.md +1 -2
  11. package/src/rules/lead/lead-brief.md +4 -5
  12. package/src/rules/lead/lead-tool.md +3 -2
  13. package/src/rules/shared/01-tool.md +28 -34
  14. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  17. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  19. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  20. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  24. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  28. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  32. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  33. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  34. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  35. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  36. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  38. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  39. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  40. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  41. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  42. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  43. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  46. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  48. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  49. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -6
  51. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +22 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +6 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +3 -4
  64. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -3
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  68. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +53 -12
  72. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  73. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  78. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +148 -15
  79. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  80. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  81. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  82. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  84. package/src/runtime/channels/backends/discord.mjs +5 -14
  85. package/src/runtime/channels/backends/telegram.mjs +0 -5
  86. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  87. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  88. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  89. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  90. package/src/runtime/media/renditions.mjs +21 -2
  91. package/src/runtime/memory/tool-defs.mjs +6 -6
  92. package/src/runtime/shared/atomic-file.mjs +53 -0
  93. package/src/runtime/shared/background-tasks.mjs +6 -3
  94. package/src/runtime/shared/resource-admission.mjs +40 -1
  95. package/src/runtime/shared/tool-status.mjs +10 -1
  96. package/src/runtime/shared/tool-surface.mjs +7 -7
  97. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  98. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  99. package/src/session-runtime/context-status.mjs +9 -3
  100. package/src/session-runtime/lifecycle-api.mjs +35 -5
  101. package/src/session-runtime/mcp-glue.mjs +11 -6
  102. package/src/session-runtime/provider-models.mjs +94 -43
  103. package/src/session-runtime/runtime-core.mjs +46 -8
  104. package/src/session-runtime/runtime-tunables.mjs +4 -0
  105. package/src/session-runtime/self-update.mjs +33 -4
  106. package/src/session-runtime/session-lifecycle.mjs +2 -0
  107. package/src/session-runtime/session-text.mjs +2 -1
  108. package/src/session-runtime/session-turn-api.mjs +106 -22
  109. package/src/session-runtime/workflow.mjs +11 -4
  110. package/src/standalone/agent-tool.mjs +4 -4
  111. package/src/standalone/backend-daemon.mjs +570 -0
  112. package/src/standalone/channel-daemon-transport.mjs +141 -1
  113. package/src/standalone/channel-worker.mjs +3 -2
  114. package/src/standalone/engine-daemon-client.mjs +894 -0
  115. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  116. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  117. package/src/standalone/engine-daemon-service.mjs +864 -0
  118. package/src/standalone/engine-daemon-transport.mjs +603 -0
  119. package/src/tui/App.jsx +62 -47
  120. package/src/tui/app/app-format.mjs +4 -2
  121. package/src/tui/app/app-view.jsx +5 -0
  122. package/src/tui/app/channel-pickers.mjs +7 -6
  123. package/src/tui/app/core-memory-picker.mjs +4 -4
  124. package/src/tui/app/extension-pickers.mjs +20 -18
  125. package/src/tui/app/maintenance-pickers.mjs +27 -27
  126. package/src/tui/app/onboarding-steps.mjs +23 -18
  127. package/src/tui/app/prompt-submit.mjs +13 -2
  128. package/src/tui/app/route-pickers.mjs +13 -8
  129. package/src/tui/app/settings-picker.mjs +55 -58
  130. package/src/tui/app/slash-dispatch.mjs +32 -28
  131. package/src/tui/app/transcript-window.mjs +19 -0
  132. package/src/tui/app/usage-context-panels.mjs +22 -7
  133. package/src/tui/app/use-mouse-input.mjs +25 -3
  134. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  135. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  136. package/src/tui/app/use-transcript-window.mjs +15 -0
  137. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  138. package/src/tui/components/PromptInput.jsx +10 -0
  139. package/src/tui/components/Spinner.jsx +89 -95
  140. package/src/tui/components/TextEntryPanel.jsx +14 -0
  141. package/src/tui/components/ToolExecution.jsx +2 -2
  142. package/src/tui/dist/index.mjs +1534 -9801
  143. package/src/tui/engine/agent-job-feed.mjs +2 -2
  144. package/src/tui/engine/live-share.mjs +76 -3
  145. package/src/tui/engine/session-api-ext.mjs +23 -1
  146. package/src/tui/engine/session-api.mjs +81 -41
  147. package/src/tui/engine/session-flow.mjs +43 -4
  148. package/src/tui/engine/tool-card-results.mjs +6 -0
  149. package/src/tui/engine/turn.mjs +8 -1
  150. package/src/tui/engine-local-session.mjs +1108 -0
  151. package/src/tui/engine.mjs +16 -1065
  152. package/src/tui/index.jsx +40 -2
  153. package/src/tui/markdown/stream-fence.mjs +1 -1
  154. package/src/tui/spinner-meta.mjs +80 -0
  155. package/src/tui/spinner-verbs.mjs +35 -0
  156. package/src/ui/statusline-segments.mjs +43 -10
  157. package/src/ui/statusline.mjs +10 -1
  158. package/scripts/tmp-cdp-errors.mjs +0 -41
  159. package/scripts/tmp-cdp-inspect.mjs +0 -41
  160. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  161. package/src/standalone/channel-daemon.mjs +0 -226
@@ -35,6 +35,12 @@ let _initChain = Promise.resolve();
35
35
  let _inFlightPromise = null;
36
36
  let _inFlightSig = null;
37
37
  let _lastAppliedSig = null;
38
+ // Provider instances are process-global inside the backend daemon. Catalog
39
+ // readers use this revision to share one raw model snapshot while still
40
+ // rebuilding their cheap route/config projection after auth/config changes.
41
+ let _providerCatalogRevision = 0;
42
+ let _startupCatalogRefreshPromise = null;
43
+ let _catalogRefreshPromise = null;
38
44
 
39
45
  // Deterministic structural signature of a provider config. Recursively sorts
40
46
  // object keys so signature equality reflects config-value equality regardless
@@ -141,7 +147,12 @@ export async function initProviders(config, { signal = null } = {}) {
141
147
  }
142
148
  };
143
149
  const tracked = next.then(
144
- (v) => { _lastAppliedSig = sig; settle(); return v; },
150
+ (v) => {
151
+ if (_lastAppliedSig !== sig) _providerCatalogRevision += 1;
152
+ _lastAppliedSig = sig;
153
+ settle();
154
+ return v;
155
+ },
145
156
  (err) => { settle(); throw err; },
146
157
  );
147
158
  _inFlightSig = sig;
@@ -236,6 +247,7 @@ export function getProvider(name) {
236
247
  if (!Ctor) return undefined;
237
248
  const inst = wrapProviderAdmission(new Ctor({}), name);
238
249
  providers.set(name, inst);
250
+ _providerCatalogRevision += 1;
239
251
  return inst;
240
252
  }
241
253
  if (name === 'openai-oauth' && hasOpenAIOAuthCredentials()) {
@@ -243,6 +255,7 @@ export function getProvider(name) {
243
255
  if (!Ctor) return undefined;
244
256
  const inst = wrapProviderAdmission(new Ctor({}), name);
245
257
  providers.set(name, inst);
258
+ _providerCatalogRevision += 1;
246
259
  return inst;
247
260
  }
248
261
  if (name === 'grok-oauth' && hasGrokOAuthCredentials()) {
@@ -250,6 +263,7 @@ export function getProvider(name) {
250
263
  if (!Ctor) return undefined;
251
264
  const inst = wrapProviderAdmission(new Ctor({}), name);
252
265
  providers.set(name, inst);
266
+ _providerCatalogRevision += 1;
253
267
  return inst;
254
268
  }
255
269
  return undefined;
@@ -287,6 +301,9 @@ export function getAllProviders() {
287
301
  // stale entries across re-init (initProviders rebuilds the map in place).
288
302
  return new Map(providers);
289
303
  }
304
+ export function providerCatalogRevision() {
305
+ return _providerCatalogRevision;
306
+ }
290
307
  // Narrow synchronous test seam for the lazy-OAuth boundary. It models a
291
308
  // constructor whose module is already loaded while guaranteeing every touched
292
309
  // registry entry is restored, even when the assertion callback throws.
@@ -311,6 +328,20 @@ export function _withLoadedProviderCtorForTest(name, Ctor, fn) {
311
328
  else signatures.delete(name);
312
329
  }
313
330
  }
331
+ // Companion seam for registry walks that iterate live INSTANCES (what
332
+ // initProviders leaves behind) rather than constructors — the startup catalog
333
+ // refresh is one. Restores the prior entry even when the callback throws.
334
+ export function _withRegisteredProviderForTest(name, instance, fn) {
335
+ const hadProvider = providers.has(name);
336
+ const priorProvider = providers.get(name);
337
+ providers.set(name, instance);
338
+ try {
339
+ return fn();
340
+ } finally {
341
+ if (hadProvider) providers.set(name, priorProvider);
342
+ else providers.delete(name);
343
+ }
344
+ }
314
345
  // Background catalog warm-up. Each provider's listModels() either hits its
315
346
  // own cached model list (no-op) or fires a single HTTP refresh. Called from
316
347
  // agent.init() after providers are registered so the first agent dispatch call
@@ -336,7 +367,9 @@ function warmupCatalogs() {
336
367
  }
337
368
  }
338
369
 
339
- // Force-refresh each provider's /models catalog on every MCP start. Unlike
370
+ // Force-refresh each provider's /models catalog ONCE per backend-daemon
371
+ // lifetime. Every session runtime joins this process-global promise and then
372
+ // reads the same provider-instance caches. Unlike
340
373
  // warmupCatalogs (which calls listModels() and so respects the 24h provider
341
374
  // TTL → no-op when the cache is fresh), this bypasses the TTL via
342
375
  // _refreshModelCache so a model released since the last refresh is picked up
@@ -345,6 +378,7 @@ function warmupCatalogs() {
345
378
  // context metadata stays on its own 24h TTL. Fire-and-forget: never awaited,
346
379
  // per-provider failures logged to stderr like warmupCatalogs.
347
380
  export function refreshProviderCatalogsOnStartup() {
381
+ if (_startupCatalogRefreshPromise) return _startupCatalogRefreshPromise;
348
382
  const pending = [];
349
383
  for (const [name, provider] of providers) {
350
384
  const refreshFn = typeof provider?._refreshModelCache === 'function'
@@ -361,7 +395,11 @@ export function refreshProviderCatalogsOnStartup() {
361
395
  // Returns a completion promise so callers can invalidate stale model
362
396
  // caches once the fresh catalogs land. Still fire-and-forget: unawaited
363
397
  // callers keep the previous nonblocking startup behavior.
364
- return Promise.allSettled(pending);
398
+ _startupCatalogRefreshPromise = Promise.allSettled(pending).then((results) => {
399
+ _providerCatalogRevision += 1;
400
+ return results;
401
+ });
402
+ return _startupCatalogRefreshPromise;
365
403
  }
366
404
 
367
405
  // Force-refresh provider catalogs after an operator changes model/provider
@@ -369,6 +407,7 @@ export function refreshProviderCatalogsOnStartup() {
369
407
  // the shared LiteLLM metadata cache first so context/pricing metadata follows
370
408
  // newly released models without waiting for the next process restart.
371
409
  export function refreshCatalogs() {
410
+ if (_catalogRefreshPromise) return _catalogRefreshPromise;
372
411
  const pending = [];
373
412
  const metadataReady = Promise.resolve()
374
413
  .then(() => refreshMetadataCatalog())
@@ -391,5 +430,13 @@ export function refreshCatalogs() {
391
430
  }));
392
431
  }
393
432
  // Completion promise: lets callers drop stale model caches after refresh.
394
- return Promise.allSettled([metadataReady, ...pending]);
433
+ _catalogRefreshPromise = Promise.allSettled([metadataReady, ...pending])
434
+ .then((results) => {
435
+ _providerCatalogRevision += 1;
436
+ return results;
437
+ })
438
+ .finally(() => {
439
+ _catalogRefreshPromise = null;
440
+ });
441
+ return _catalogRefreshPromise;
395
442
  }
@@ -252,7 +252,7 @@ function isRetryable(err) {
252
252
  return classifyError(err) === 'transient'
253
253
  }
254
254
 
255
- /** Claude Code compatible Anthropic request budget: 10 retries (11 attempts).
255
+ /** Anthropic request budget: 10 retries (11 attempts).
256
256
  * CLAUDE_CODE_MAX_RETRIES is intentionally read per request for reload/tests.
257
257
  * The upper bound prevents an accidental unbounded retry loop. */
258
258
  export function anthropicMaxAttempts() {
@@ -262,16 +262,16 @@ export function anthropicMaxAttempts() {
262
262
  return retries + 1
263
263
  }
264
264
 
265
- // Claude Code request defaults (withRetry.ts): 500ms exponential backoff,
265
+ // Anthropic retry defaults: 500ms exponential backoff,
266
266
  // capped at 32s, with positive-only jitter up to 25% of the base delay.
267
- // The leading duplicate accounts for withRetry's sleep-before-attempt index:
267
+ // The leading duplicate accounts for the sleep-before-attempt index:
268
268
  // retry attempt 2 reads index 1.
269
269
  export const ANTHROPIC_RETRY_BACKOFF_MS = Object.freeze([
270
270
  500, 500, 1000, 2000, 4000, 8000, 16000, 32000, 32000, 32000, 32000,
271
271
  ])
272
272
  export const ANTHROPIC_RETRY_JITTER_RATIO = 0.25
273
273
 
274
- // Claude Code's Anthropic SDK client defaults API_TIMEOUT_MS to ten minutes.
274
+ // The Anthropic SDK client defaults API_TIMEOUT_MS to ten minutes.
275
275
  // Read per request, like CLAUDE_CODE_MAX_RETRIES, so env reload/tests work.
276
276
  export function anthropicRequestTimeoutMs() {
277
277
  const parsed = Number.parseInt(process.env.API_TIMEOUT_MS || '', 10)
@@ -318,10 +318,10 @@ export function jitterDelayMs(ms, ratio = PROVIDER_RETRY_JITTER_RATIO, mode = 's
318
318
  // Mid-stream 'stream_stalled' recoveries retry in place, which is right for a
319
319
  // one-off blip but lets a chronically dying stream burn a whole task budget
320
320
  // slowly (observed live: one send stretched 149s→298s→556s across stall
321
- // retries before the agent deadline killed the task). Reference stacks bound
322
- // this instead of retrying forever: Claude Code caps each request at ~300s
323
- // wall clock (API_TIMEOUT_MS) and Codex kills a stream after one 300s silent
324
- // gap (stream_idle_timeout). This guard is the equivalent for our in-place
321
+ // retries before the agent deadline killed the task). A stalling stream is
322
+ // bounded instead of retried forever: a request is capped at ~300s wall
323
+ // clock (API_TIMEOUT_MS) and a stream dies after one 300s silent gap
324
+ // (stream idle timeout). This guard is the equivalent for our in-place
325
325
  // recovery: the clock starts at the FIRST stall of a send, and stall-classified
326
326
  // retries are allowed only inside that window; past it the stall error
327
327
  // surfaces so loop-level transport retry issues a FRESH request. Healthy
@@ -357,7 +357,7 @@ export function createStallRetryBudget(budgetMs = STREAM_STALL_RETRY_BUDGET_MS,
357
357
  // branched on a hardcoded provider name.
358
358
 
359
359
  // F) Retry-budget profiles as DATA. The numbers live ONLY here now.
360
- // ws.*Retries (5) — one Codex Responses stream retry budget.
360
+ // ws.*Retries (5) — one Responses stream retry budget.
361
361
  // sse.defaultRetries (3) — anthropic single-shot SSE mid-stream budget.
362
362
  export const MIDSTREAM_RETRY_POLICY = {
363
363
  ws: { transientCloseRetries: 5, defaultRetries: 5, backoff: [250, 1000, 2000, 4000, 5000] },
@@ -490,7 +490,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
490
490
  }
491
491
 
492
492
  // Explicit `response.failed` error codes/types that describe a transport-level
493
- // interruption (Codex retries these); every other code is terminal.
493
+ // interruption (these are retryable); every other code is terminal.
494
494
  const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
495
495
  ['stream_disconnected', 'response_failed_disconnected'],
496
496
  ['network_error', 'response_failed_network'],
@@ -550,7 +550,7 @@ export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
550
550
  if (signal?.aborted) return false
551
551
  // Transport fallback re-issues the request on another transport: it is a
552
552
  // replay, so the exposure deny applies. Eligibility itself stays typed
553
- // (status / errno / classifier), matching Codex's WS→HTTPS switch.
553
+ // (status / errno / classifier) for the WS→HTTPS switch.
554
554
  if (readStreamOutcome(err).replaySafe !== true) return false
555
555
  const status = Number(err?.httpStatus || err?.status || 0)
556
556
  // 401 is auth recovery, never transport fallback. 426 is the explicit
@@ -660,7 +660,7 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
660
660
  // E) Handshake classifier (moved here from openai-oauth-ws). Default-deny:
661
661
  // anything not recognized as transient returns null. HTTP 401 is reserved
662
662
  // for auth recovery and 426 for immediate HTTPS fallback. The OpenAI OAuth
663
- // caller opts out of 429 retries (Codex retry_429:false); all other callers
663
+ // caller opts out of 429 retries (retry429:false); all other callers
664
664
  // retain the historical retryable UnexpectedStatus policy.
665
665
  export function classifyHandshakeError(err, { retry429 = true } = {}) {
666
666
  if (!err) return null
@@ -764,8 +764,8 @@ export async function withRetry(fn, opts = {}) {
764
764
  // an eligible failure is actually retried remains the typed question
765
765
  // resolved by classifyError()/status below.
766
766
  if (readStreamOutcome(caught).replaySafe !== true) throw caught
767
- // Claude Code treats x-should-retry:false as an explicit server veto
768
- // (except an internal-only 5xx override that Mixdog does not have).
767
+ // x-should-retry:false is an explicit server veto on retrying and is
768
+ // honored as-is.
769
769
  // Keep this ahead of status defaults, including the request-local 429 path.
770
770
  const shouldRetryHeader = _headerValue(
771
771
  caught?.headers || caught?.response?.headers || caught?.data?.responseHeaders,
@@ -786,7 +786,7 @@ export async function withRetry(fn, opts = {}) {
786
786
  }
787
787
  continue
788
788
  }
789
- // Claude Code's optional model fallback fires on the third 529. This
789
+ // The optional model fallback fires on the third 529. This
790
790
  // remains opt-in: providers pass fallbackModel only when the caller set
791
791
  // one. The hard progress veto above must run first so fallback can never
792
792
  // replay partial thinking/tool output.
@@ -91,7 +91,6 @@ import {
91
91
  isOutputLimitStopReason,
92
92
  providerContinuationSignal,
93
93
  } from './loop/termination.mjs';
94
- import { createSteeringLadder } from './loop/steering-ladder.mjs';
95
94
  import { runPreSendCompactPass } from './pre-send-compact.mjs';
96
95
  import { createEagerDispatcher } from './eager-dispatch.mjs';
97
96
  import { sendWithRecovery } from './send-with-recovery.mjs';
@@ -238,7 +237,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
238
237
  if (compactSettledToolCallBodies(messages) && !opts.cacheBreakIntent) {
239
238
  opts.cacheBreakIntent = 'deferred_body_compaction';
240
239
  }
241
- // ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
240
+ // ---- Turn stop hook ----------------------------------------------------
242
241
  // A no-tool assistant message is TERMINAL. Only a structured provider
243
242
  // follow-up signal (end_turn=false / pause_turn), pending input, tool
244
243
  // calls/results, or a stop hook that blocks with a continuation prompt keep
@@ -280,8 +279,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
280
279
  }
281
280
  // Tag steering-origin user messages so provider lowering keeps them
282
281
  // distinct from preceding tool results. Keep each queued command as
283
- // its own user turn, matching Claude Code queued_command attachment
284
- // semantics instead of collapsing priority/mode buckets together.
282
+ // its own user turn instead of collapsing priority/mode buckets
283
+ // together.
285
284
  messages.push({
286
285
  role: 'user',
287
286
  content: merged.content,
@@ -366,9 +365,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
366
365
  };
367
366
  const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
368
367
  // ---- Completion-first loop guards (worker runaway prevention) ----
369
- // Step 1 (escalation ladder) + the missed-parallelism / serial-rewording
370
- // steering hints live in the createSteeringLadder controller below; it owns
371
- // their cumulative counters and emits at most one hint per turn.
368
+ // Behavior-steering hints (missed-parallelism, all-read-only, read-only
369
+ // shell, level-2 "stop exploring") were removed: they nudged tool shape
370
+ // instead of protecting resources. Only the staged iteration warnings, the
371
+ // hard cap, and the cross-turn dedup stub remain.
372
372
  // _editCount counts any executed tool call whose def lacks readOnlyHint
373
373
  // (i.e. edit/progress: apply_patch, bash, MCP writes, skills, ...).
374
374
  let _editCount = 0;
@@ -406,7 +406,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
406
406
  // Loop-level transport replays consumed this ask (see send-with-recovery
407
407
  // TRANSPORT_RETRY_MAX): bounded per turn, reset only with a fresh ask.
408
408
  let _transportRetriesUsed = 0;
409
- // Claude Code parity: queued prompt/task notifications are attached after a
409
+ // Queued prompt/task notifications are attached after a
410
410
  // tool batch, before the continuation provider send. Normal batches drain
411
411
  // up to 'next'; a Sleep-like tool grants a 'later' flush.
412
412
  let _toolBatchJustCompleted = false;
@@ -415,20 +415,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
415
415
  const name = String(call?.name || call?.toolName || call?.function?.name || '').toLowerCase();
416
416
  return name === 'sleep' || name.endsWith('/sleep') || name.endsWith('.sleep');
417
417
  };
418
- // Completion-first steering ladder controller. Owns the (cumulative) level-1
419
- // fire count, the all-read-only / serial-single / same-file-grep streaks,
420
- // and the level-2 latch. Threaded via live getters so it reads the loop's
421
- // current `iterations` / `_editCount` on every call (no stale snapshots).
422
- const _steeringLadder = createSteeringLadder({
423
- sessionId,
424
- sessionAgent,
425
- tools,
426
- getIterations: () => iterations,
427
- getEditCount: () => _editCount,
428
- readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
429
- pushUserMessage: (msg) => messages.push(msg),
430
- pushSystemReminder: (text) => messages.push({ role: 'user', content: `<system-reminder>\n${text}\n</system-reminder>`, meta: 'hook' }),
431
- });
432
418
  // Tool execution must use the session cwd even when the caller omitted the
433
419
  // legacy positional cwd argument. Agent workers always carry their cwd on
434
420
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
@@ -503,8 +489,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
503
489
  } catch { /* best-effort */ }
504
490
  }
505
491
  // Drain queued steering/prompts BEFORE the pre-send compact check, but
506
- // only immediately after a tool batch has completed. This mirrors
507
- // Claude Code's query.ts queued_command attachment drain: queued entries
492
+ // only immediately after a tool batch has completed: queued entries
508
493
  // are attached after tool results are appended and before the recursive
509
494
  // continuation, not on arbitrary non-tool continuations (empty nudges,
510
495
  // iteration-cap final text turns, etc.).
@@ -792,11 +777,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
792
777
  // tool-call-blocked vs contract-required oscillation.
793
778
  if (!response.toolCalls?.length) {
794
779
  // No tool calls. Decide between final-answer accept vs nudge.
795
- // Reviewer fix: a zero-tool turn (final-pre-send steering drain or
796
- // contract nudge `continue`) must not bridge the all-read-only
797
- // streak across non-tool turns — that would fire level-2 early on
798
- // a worker that paused to synthesize text mid-run.
799
- _steeringLadder.resetAllReadOnlyStreak();
800
780
  // - has content + non-hidden role → valid final, break.
801
781
  // - empty content + hidden role → contract allows text-only
802
782
  // terminal turn, break.
@@ -915,7 +895,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
915
895
  messages.push({ role: 'user', content: nudgeMsg });
916
896
  continue;
917
897
  }
918
- // Codex `has_pending_input` (turn.rs:304-318): queued user input is
898
+ // Pending-input rule: queued user input is
919
899
  // folded into needs_follow_up and evaluated BEFORE the stop hooks,
920
900
  // so real steering always wins over a synthetic continuation prompt.
921
901
  // Commit the terminal text first (beforeAppend), then resume.
@@ -926,7 +906,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
926
906
  _emptyNudgeStreak = 0;
927
907
  continue;
928
908
  }
929
- // Codex parity (turn.rs:372-404): this no-tool message ends the turn
909
+ // This no-tool message ends the turn
930
910
  // unless a stop hook blocks it. The unresolved-tool-failure hook may
931
911
  // block exactly once — commit the assistant text, record the
932
912
  // structural continuation prompt, resume sampling. Skipped on the
@@ -1064,7 +1044,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1064
1044
  pending: eager.pending, epoch: eager.epoch, startEagerRun: eager.startEagerRun,
1065
1045
  crossTurnCalls: _crossTurnCalls, crossTurnCap: _CROSS_TURN_CAP,
1066
1046
  dedupStubTotal: _dedupStubTotal, editCount: _editCount,
1067
- sessionAgent, steeringLadder: _steeringLadder,
1047
+ sessionAgent,
1068
1048
  pushToolResultMessage, throwIfAborted,
1069
1049
  repeatFailLimit: REPEAT_FAIL_LIMIT,
1070
1050
  }));
@@ -3,6 +3,8 @@
3
3
  import { _normalizeAbs, _statTuple, _statEqual } from './util.mjs';
4
4
  import { clearScopedToolsForSession, clearScopedCounters } from './scoped-cache.mjs';
5
5
  import { clearPostEditMarks } from './post-edit-marks.mjs';
6
+ import { registerSessionPurgeHook } from '../store.mjs';
7
+ import { releaseReadSnapshotScope } from '../../tools/builtin/snapshot-store.mjs';
6
8
 
7
9
  const MAX_PER_SESSION = 100;
8
10
 
@@ -260,6 +262,11 @@ export function clearReadDedupSession(sessionId) {
260
262
  clearScopedCounters(sessionId);
261
263
  }
262
264
 
265
+ registerSessionPurgeHook((sessionId) => {
266
+ clearReadDedupSession(sessionId);
267
+ releaseReadSnapshotScope(sessionId, { deletePersisted: true, persist: false });
268
+ });
269
+
263
270
  /**
264
271
  * Extract the set of touched filesystem paths from a unified-diff patch text.
265
272
  * Handles git-style `--- a/<path>` / `+++ b/<path>` headers and `/dev/null` markers.
@@ -2,7 +2,9 @@ import { isAgentOwner } from '../agent-owner.mjs';
2
2
 
3
3
  export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
4
4
  export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
5
- export const DEFAULT_MAIN_COMPACTION_BUFFER_RATIO = 0.05;
5
+ // Main/user sessions default to full-window trigger (buffer 0): RFT compact
6
+ // runs at the boundary. Agent sessions keep DEFAULT_COMPACTION_BUFFER_RATIO.
7
+ export const DEFAULT_MAIN_COMPACTION_BUFFER_RATIO = 0;
6
8
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
7
9
  const MAX_BUFFER_INPUT_RATIO = 0.999_999;
8
10
  export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
@@ -16,9 +18,14 @@ function envTokenInt(name) {
16
18
  return positiveTokenInt(process.env[name]);
17
19
  }
18
20
  export function normalizeCompactionBufferRatio(value, fallback = DEFAULT_COMPACTION_BUFFER_RATIO) {
21
+ // Explicit 0 is a valid buffer (main full-window trigger). Only nullish /
22
+ // empty / non-finite / negative values fall back. Do not treat Number(null)
23
+ // as zero here — null must mean "unset".
24
+ if (value == null || value === '') return fallback;
19
25
  const n = Number(value);
20
- if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
21
- return fallback;
26
+ if (!Number.isFinite(n) || n < 0) return fallback;
27
+ if (n === 0) return 0;
28
+ return n > 1 ? n / 100 : n;
22
29
  }
23
30
  export function resolveBufferRatioCandidate(percentInputs = [], ratioInputs = []) {
24
31
  for (const raw of percentInputs) {
@@ -160,11 +160,10 @@ export function providerTokenCalibration(provider) {
160
160
  if (p.startsWith('gemini') || p.startsWith('google')) return calibrationEnv('MIXDOG_TOKEN_CALIBRATION_GEMINI') ?? 1.15;
161
161
  return calibrationEnv('MIXDOG_TOKEN_CALIBRATION_DEFAULT') ?? 1.0;
162
162
  }
163
- // Claude Code parity (services/tokenEstimation.ts): images/documents count a
164
- // flat 2000 tokens when dimensions are unknown — the conservative constant CC
165
- // shares with microCompact's IMAGE_MAX_TOKEN_SIZE. Known dimensions may only
166
- // RAISE the allowance via Anthropic's real vision formula (w*h/750), capped at
167
- // the 2000x2000 resize ceiling (5333 tokens).
163
+ // Images/documents count a flat 2000 tokens when dimensions are unknown —
164
+ // the conservative constant also used by the micro-compaction path. Known
165
+ // dimensions may only RAISE the allowance via Anthropic's real vision
166
+ // formula (w*h/750), capped at the 2000x2000 resize ceiling (5333 tokens).
168
167
  export const IMAGE_VISUAL_TOKEN_ALLOWANCE = 2_000;
169
168
  const IMAGE_MAX_TOKEN_ALLOWANCE = 5_333;
170
169
 
@@ -249,8 +248,8 @@ function legacyEstimateTokens(s) {
249
248
  // for those runs without penalizing ordinary spaced prose.
250
249
  let denseAsciiFloor = 0;
251
250
  for (const match of s.matchAll(/[\x21-\x7e]{16,}/g)) {
252
- // Claude Code prices dense JSON/JSONL at chars/2 (bytesPerTokenForFileType);
253
- // 0.5/char keeps that parity for long unmerged printable runs.
251
+ // Dense JSON/JSONL prices at chars/2; 0.5/char keeps that ratio for
252
+ // long unmerged printable runs.
254
253
  denseAsciiFloor += match[0].length * 0.5;
255
254
  }
256
255
  const encodedWords = s.match(/\b(?=[A-Za-z0-9]{8,}\b)(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]+\b/g) || [];
@@ -272,7 +271,7 @@ function legacyEstimateTokens(s) {
272
271
  && (jsonLikeLines >= Math.ceil(lines.length / 2) || structural / nonWhitespace >= 0.12)) {
273
272
  // JSONL, compact tables and generated line protocols can consist
274
273
  // entirely of short runs while still tokenizing like minified data.
275
- // chars/2 on the dense payload chars (Claude Code JSON parity).
274
+ // chars/2 on the dense payload chars (dense-JSON pricing).
276
275
  denseAsciiFloor = Math.max(denseAsciiFloor, (nonWhitespace * 0.5) + ((s.length - nonWhitespace) * 0.25));
277
276
  }
278
277
  const asciiFloor = s.length / 4; // never below the legacy chars/4 lower bound
@@ -355,8 +354,8 @@ function messageEstimateText(m) {
355
354
  function imageDescriptorAllowance(descriptor) {
356
355
  if (descriptor.width && descriptor.height) {
357
356
  // Anthropic vision cost: tokens = (width * height) / 750, with images
358
- // resized down to at most 2000x2000 (5333 tokens) — Claude Code uses
359
- // the same formula/cap. Caller-supplied dimensions may RAISE the
357
+ // resized down to at most 2000x2000 (5333 tokens). Caller-supplied
358
+ // dimensions may RAISE the
360
359
  // allowance above the unknown-image floor but never lower it (the
361
360
  // provider normalizer may not preserve caller metadata).
362
361
  const formula = Math.ceil((descriptor.width * descriptor.height) / 750);
@@ -365,7 +364,7 @@ function imageDescriptorAllowance(descriptor) {
365
364
  Math.max(IMAGE_VISUAL_TOKEN_ALLOWANCE, formula),
366
365
  );
367
366
  }
368
- // Unknown-size images: flat conservative allowance (Claude Code parity).
367
+ // Unknown-size images: flat conservative allowance.
369
368
  return IMAGE_VISUAL_TOKEN_ALLOWANCE;
370
369
  }
371
370
  function messageImageDescriptors(m) {
@@ -100,6 +100,7 @@ export function createEagerDispatcher({
100
100
  if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
101
101
  entry.promise = (async () => {
102
102
  try {
103
+ await opts.beforeToolExecution?.();
103
104
  return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration(), deferShellCwdCommit: true }) };
104
105
  } catch (error) {
105
106
  return { ok: false, error };
@@ -112,8 +112,8 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
112
112
  if (!boundaryTokens) return null;
113
113
  const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
114
114
  // Shared session-compaction policy (context-utils): agent semantic keeps the
115
- // default early-trigger buffer (90%); main/user use their independently
116
- // configurable 25% default headroom (75%);
115
+ // default early-trigger buffer (90%); main/user default to full-window
116
+ // trigger (buffer 0 / 100%), still overridable via mainBuffer*;
117
117
  // a truly-explicit sub-boundary limit wins. explicitAutoCompactTokenLimit
118
118
  // is the sanitized (null when legacy full-window) value so telemetry never
119
119
  // re-persists a boundary-collapsing limit.
@@ -216,7 +216,7 @@ function providerPressureTokens(sessionRef, usage) {
216
216
  /**
217
217
  * Align an authoritative provider usage snapshot to the message prefix it
218
218
  * covers. Later pressure checks add estimates only for messages after this
219
- * baseline, matching Claude Code's actual-usage-plus-growth accounting.
219
+ * baseline: actual-usage-plus-growth accounting.
220
220
  */
221
221
  export function recordProviderContextBaseline(sessionRef, messages, usage, {
222
222
  boundary = 'complete',
@@ -38,20 +38,6 @@ export function isEditProgressTool(name, isEager) {
38
38
  return !NON_PROGRESS_TOOLS.has(bare);
39
39
  }
40
40
 
41
- // Step 1 — level-2 escalation steering. N = cumulative level-1 fires.
42
- // `readOnlyRole` swaps the edit-oriented directive for a report-oriented one:
43
- // read-permission sessions (reviewer-style) cannot apply_patch, so telling
44
- // them to edit is self-contradictory and pushes premature termination.
45
- const LEVEL2_DIMINISHING_RETURNS = ' Further reads add cost, not evidence.';
46
-
47
- export function level2SteerMessage(n, readOnlyRole = false, level2Fires = 1) {
48
- const tail = Number(level2Fires) >= 2 ? LEVEL2_DIMINISHING_RETURNS : '';
49
- if (readOnlyRole) {
50
- return `<system-reminder>\nEnough evidence gathered — report findings now; name any gaps.${tail}\n</system-reminder>`;
51
- }
52
- return `<system-reminder>\nStop exploring — apply the edit you know, or report blocked with what's missing.${tail}\n</system-reminder>`;
53
- }
54
-
55
41
  // Step 2 — cross-turn dedup stub. `stuck` appends the escalation tail at the
56
42
  // 5th+ dedup stub in the session.
57
43
  export function crossTurnDedupStub(name, firstIteration, stuck) {
@@ -1,11 +1,12 @@
1
- // Codex-style turn stop hook (refs/codex core/src/session/turn.rs:372-404).
1
+ // Turn stop hook.
2
2
  //
3
- // Codex semantics: a sampling request that needs no follow-up ends the turn.
4
- // Before breaking, the turn runs its stop hooks; a hook may block ONCE by
5
- // returning continuation fragments, which are recorded as a prompt message
6
- // before sampling resumes (`stop_hook_active = true`, never cleared inside the
7
- // turn). Nothing else can force another turn: no mandatory terminal tool, no
8
- // completion strikes, no lexical inspection of the assistant text.
3
+ // Termination semantics: a sampling request that needs no follow-up ends the
4
+ // turn. Before breaking, the turn runs its stop hooks; a hook may block ONCE
5
+ // by returning continuation fragments, which are recorded as a prompt message
6
+ // before sampling resumes (the hook stays armed for the rest of the turn and
7
+ // is never cleared inside it). Nothing else can force another turn: no
8
+ // mandatory terminal tool, no completion strikes, no lexical inspection of
9
+ // the assistant text.
9
10
  //
10
11
  // The single hook implemented here preserves the original failed-apply_patch
11
12
  // protection: while a real tool failure is unresolved, the first terminal
@@ -41,7 +42,7 @@ export function toolFailureContinuationPrompt(failedTool) {
41
42
  * success clears it.
42
43
  * - takeContinuationPrompt(): returns the continuation prompt the FIRST time
43
44
  * a terminal assistant message arrives while a failure is unresolved, then
44
- * stays silent for the rest of the turn (Codex `stop_hook_active`).
45
+ * stays silent for the rest of the turn.
45
46
  */
46
47
  export function createToolFailureStopHook() {
47
48
  let unresolvedFailure = false;
@@ -672,11 +672,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
672
672
  },
673
673
  onToolResult: _trackToolResult,
674
674
  onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
675
+ beforeToolExecution: typeof askOpts?.beforeToolExecution === 'function'
676
+ ? askOpts.beforeToolExecution
677
+ : undefined,
675
678
  onCompactEvent: (event) => {
676
679
  _scheduleTurnCheckpoint(true);
677
680
  try { askOpts?.onCompactEvent?.(event); } catch {}
678
681
  },
679
- // Claude Code parity: mid-chain queued prompt/notification
682
+ // Mid-chain queued prompt/notification
680
683
  // drain is owned by agentLoop at provider-continuation
681
684
  // boundaries (after a tool batch, before the next send).
682
685
  // The post-loop _pendingTail drain below still handles
@@ -692,9 +695,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
692
695
  // Manager/pending-messages entries carry no
693
696
  // mode/priority/slash metadata, so they stay OUT of the
694
697
  // mid-chain (post-tool-batch) drain — that would bypass
695
- // Claude Code's queued_command filters. At the TERMINAL
696
- // boundary they are exactly Codex `has_pending_input`
697
- // (turn.rs:304-318): an `agent type=send` queued while
698
+ // the queued-command filters. At the TERMINAL boundary
699
+ // they are exactly pending input: an `agent type=send`
700
+ // queued while
698
701
  // the terminal sample was in flight must be folded into
699
702
  // THIS turn before any stop hook runs, instead of losing
700
703
  // its slot to a synthetic continuation prompt. The mutex
@@ -210,7 +210,7 @@ export function resolveSessionContextMeta(provider, model, seed = {}) {
210
210
  }
211
211
  export function compactTriggerForSession(session, boundaryTokens) {
212
212
  // Delegates to the shared session-compaction policy (context-utils):
213
- // agent semantic -> 90% (default buffer), main/user -> 75% (default),
213
+ // agent semantic -> 90% (default buffer), main/user -> 100% (buffer 0),
214
214
  // truly-explicit sub-boundary limit wins.
215
215
  return resolveSessionCompactPolicy(session, boundaryTokens).triggerTokens;
216
216
  }
@@ -150,7 +150,7 @@ export function _runCleanupCycle() {
150
150
  // Drain every settled runtime entry on each pass, not just the one or two
151
151
  // sessions whose on-disk idle TTL happened to expire in this interval.
152
152
  _sweepTerminalSessionRuntimes();
153
- sweepOrphanedPendingMessages();
153
+ await sweepOrphanedPendingMessages();
154
154
  await sweepIdleSessions({ includeTombstones: true });
155
155
  // Reclaim same-process session snapshots whose state is durable on disk
156
156
  // (memory-leak guard: _liveSessions used to grow for process lifetime).