mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -6,8 +6,10 @@ import { executeInternalTool } from '../../internal-tools.mjs';
6
6
  import { classifyResultKind } from '../result-classification.mjs';
7
7
  import { tryPrefetchCached, setPrefetchCached } from '../read-dedup.mjs';
8
8
  import { _executeCodeGraphToolLazy } from './runtime-loaders.mjs';
9
+ import { runAbortable, throwIfAborted } from '../../../../shared/abort-race.mjs';
9
10
 
10
- export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
11
+ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch, signal = null) {
12
+ throwIfAborted(signal);
11
13
  if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
12
14
  if (!isAgentOwner(session)) return null;
13
15
  const parts = [];
@@ -52,7 +54,10 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
52
54
  totalEntries.push(...files);
53
55
  // R20: per-file prefetch cache (cross-dispatch, process-local).
54
56
  // Try each file from cache first; batch misses into one disk read.
55
- const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await import('path');
57
+ const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await runAbortable(
58
+ signal,
59
+ () => import('path'),
60
+ );
56
61
  const _pfCwd = session.cwd || null;
57
62
  function _pfAbsPath(f) {
58
63
  const abs = _pfIsAbs(f) ? f : _pfResolve(_pfCwd || process.cwd(), f);
@@ -79,7 +84,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
79
84
  if (missFiles.length > 0) {
80
85
  // Read each miss file individually so we can cache per-file.
81
86
  // The files list is small (typically 2-5), so N awaits is fine.
82
- await Promise.all(missFiles.map(async (f) => {
87
+ await runAbortable(signal, () => Promise.all(missFiles.map(async (f) => {
83
88
  const opts = _readOptsByFile.get(f) || {};
84
89
  const readArgs = { path: f };
85
90
  if (opts.mode === 'full') {
@@ -95,7 +100,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
95
100
  if (out !== null) {
96
101
  missResults[f] = String(out);
97
102
  }
98
- }));
103
+ })));
99
104
  // Cache successful miss results.
100
105
  for (const { file, abs } of fileMisses) {
101
106
  const content = missResults[file];
@@ -157,7 +162,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
157
162
  return { symbol, out: null };
158
163
  });
159
164
  });
160
- const callerResults = await Promise.allSettled(callerTasks);
165
+ const callerResults = await runAbortable(signal, () => Promise.allSettled(callerTasks));
161
166
  for (const r of callerResults) {
162
167
  const { symbol, out } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
163
168
  if (out && classifyResultKind(String(out)) !== 'error') {
@@ -182,7 +187,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
182
187
  return { symbol, out: null };
183
188
  });
184
189
  });
185
- const refResults = await Promise.allSettled(refTasks);
190
+ const refResults = await runAbortable(signal, () => Promise.allSettled(refTasks));
186
191
  for (const r of refResults) {
187
192
  const { symbol, out } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
188
193
  if (out && classifyResultKind(String(out)) !== 'error') {
@@ -192,6 +197,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
192
197
  }
193
198
  }
194
199
  }
200
+ throwIfAborted(signal);
195
201
  if (session && typeof session === 'object' && (callers.length > 0 || references.length > 0)) {
196
202
  if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0, callers: 0, references: 0 };
197
203
  session.prefetchStats.callers = (session.prefetchStats.callers || 0) + callers.length;
@@ -17,6 +17,10 @@ export async function _getAgentLoop() {
17
17
  if (typeof mod.agentLoop !== 'function') throw new Error('agent loop runtime is not available');
18
18
  return mod.agentLoop;
19
19
  }
20
+ export async function prewarmAgentLoop() {
21
+ await _getAgentLoop();
22
+ return true;
23
+ }
20
24
  export function _closeBashSessionLazy(sessionId, reason) {
21
25
  if (!sessionId) return;
22
26
  _bashSessionRuntimePromise ??= import('../../tools/bash-session.mjs');
@@ -6,7 +6,7 @@ import { loadSession, saveSessionAsync, setLiveSession, evictLiveSession, listSt
6
6
  import { estimateMessagesTokens, estimateTranscriptContextUsage } from '../context-utils.mjs';
7
7
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE, SUMMARY_PREFIX } from '../compact.mjs';
8
8
  import { runSessionCompaction } from './compaction-runner.mjs';
9
- import { hasUserConversationMessage } from './prompt-utils.mjs';
9
+ import { hasUserConversationMessage, promptContentText } from './prompt-utils.mjs';
10
10
  import { getProvider } from '../../providers/registry.mjs';
11
11
  import { isSessionCompactionBlocked, getSessionAbortSignal, _runtimeEntries } from './runtime-liveness.mjs';
12
12
  import { mintSessionId } from './session-id.mjs';
@@ -314,3 +314,33 @@ export async function updateSessionStatus(id, status) {
314
314
  await saveSessionAsync(session, { expectedGeneration: session.generation });
315
315
  return true;
316
316
  }
317
+
318
+ // --- Rewind to a previous user message (message selector) ---
319
+ // Claude Code's "restore conversation": the selected prompt and everything
320
+ // after it leave the model history so the user can edit and resubmit it.
321
+ // Earlier turns — including any compaction rewrite that precedes them — stay
322
+ // exactly as they are. Idle-only; the caller interrupts a live turn first.
323
+ export async function rewindSessionMessagesTo(sessionId, options = {}) {
324
+ const session = loadSession(sessionId);
325
+ if (!session || session.closed === true) return null;
326
+ const target = String(options?.text ?? '').trim();
327
+ if (!target) return null;
328
+ const messages = Array.isArray(session.messages) ? session.messages : [];
329
+ let start = -1;
330
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
331
+ const message = messages[index];
332
+ if (message?.role !== 'user') continue;
333
+ if (promptContentText(message.content).trim() !== target) continue;
334
+ start = index;
335
+ break;
336
+ }
337
+ if (start < 0) return null;
338
+ const removed = messages.length - start;
339
+ session.messages = messages.slice(0, start);
340
+ // The provider cache is keyed on the exact prefix we just truncated.
341
+ session.providerState = undefined;
342
+ session.updatedAt = Date.now();
343
+ session.lastUsedAt = Date.now();
344
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
345
+ return { removed, remaining: session.messages.length };
346
+ }
@@ -2,8 +2,10 @@
2
2
  // Per-session mutex extracted verbatim from manager.mjs. Queues concurrent
3
3
  // askSession calls (and their drained pending-message tail turns) to prevent
4
4
  // message loss / interleaving.
5
+ import { abortReason } from '../../../../shared/abort-race.mjs';
6
+
5
7
  const _sessionLocks = new Map();
6
- export function acquireSessionLock(sessionId) {
8
+ export function acquireSessionLock(sessionId, signal = null) {
7
9
  let entry = _sessionLocks.get(sessionId);
8
10
  if (!entry) {
9
11
  entry = { promise: Promise.resolve(), count: 0 };
@@ -15,9 +17,43 @@ export function acquireSessionLock(sessionId) {
15
17
  entry.promise = new Promise(r => { release = r; });
16
18
  // Self-heal: if the previous holder rejected, swallow so subsequent
17
19
  // queued waiters don't propagate that rejection and brick the lock chain.
18
- return prev.catch(() => {}).then(() => () => {
20
+ const previousReady = prev.catch(() => {});
21
+ let slotReleased = false;
22
+ const releaseSlot = () => {
23
+ if (slotReleased) return;
24
+ slotReleased = true;
19
25
  entry.count--;
20
26
  if (entry.count === 0) _sessionLocks.delete(sessionId);
21
27
  release();
28
+ };
29
+ const unlock = () => releaseSlot();
30
+ if (!signal) return previousReady.then(() => unlock);
31
+
32
+ // A cancelled waiter must reject immediately without opening a hole in the
33
+ // mutex. Its reserved slot is skipped only after the previous holder exits,
34
+ // so later waiters still cannot overlap the live owner.
35
+ return new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const removeAbort = () => {
38
+ try { signal.removeEventListener('abort', onAbort); } catch {}
39
+ };
40
+ const onAbort = () => {
41
+ if (settled) return;
42
+ settled = true;
43
+ removeAbort();
44
+ void previousReady.then(releaseSlot);
45
+ reject(abortReason(signal, `Session "${sessionId}" lock wait aborted`));
46
+ };
47
+ signal.addEventListener('abort', onAbort, { once: true });
48
+ if (signal.aborted) {
49
+ onAbort();
50
+ return;
51
+ }
52
+ previousReady.then(() => {
53
+ if (settled) return;
54
+ settled = true;
55
+ removeAbort();
56
+ resolve(unlock);
57
+ });
22
58
  });
23
59
  }
@@ -41,18 +41,25 @@ function assistantToolCallIds(message) {
41
41
  return ids;
42
42
  }
43
43
 
44
- function rewindProvisionalUserTurn(messages, currentUserContent) {
44
+ function provisionalUserTurnIndex(messages, currentUserContent) {
45
45
  const currentText = promptContentText(currentUserContent);
46
46
  for (let i = messages.length - 1; i >= 0; i -= 1) {
47
47
  const message = messages[i];
48
48
  if (message?.role !== 'user') continue;
49
49
  if (message.content === currentUserContent
50
50
  || promptContentText(message.content) === currentText) {
51
+ return i;
52
+ }
53
+ }
54
+ return -1;
55
+ }
56
+
57
+ function rewindProvisionalUserTurn(messages, currentUserContent) {
58
+ const index = provisionalUserTurnIndex(messages, currentUserContent);
59
+ if (index >= 0) {
51
60
  // Remove the provisional user turn plus pre-send hooks appended
52
61
  // after it, while retaining any compaction rewrite before it.
53
- messages.splice(i);
54
- break;
55
- }
62
+ messages.splice(index);
56
63
  }
57
64
  return messages;
58
65
  }
@@ -69,13 +76,26 @@ function finalizeInterruptedTurn({
69
76
  abortReason,
70
77
  }) {
71
78
  const messages = filterModelVisibleSessionMessages(turnOutgoing).slice();
79
+ // Null/unknown reasons keep the legacy rewind (status quo for wrapped
80
+ // aborts without a closeReason enum); named non-user reasons preserve.
81
+ const userCancelled = abortReason == null
82
+ || USER_CANCEL_ABORT_REASONS.has(abortReason);
83
+ // Claude Code parity: a turn cancelled while the model was only THINKING
84
+ // produced no model-visible message, so the provisional user turn is
85
+ // rewound and the prompt goes back to the input box. Anything the model
86
+ // actually said or did — committed messages from this turn, buffered text,
87
+ // tool calls/results — keeps the turn (reasoning alone does not).
88
+ const producedOutput = String(partialAssistantContent || '').trim() !== ''
89
+ || observedToolCalls.size > 0
90
+ || observedToolResults.size > 0
91
+ || (() => {
92
+ const index = provisionalUserTurnIndex(messages, currentUserContent);
93
+ return index >= 0 && index < messages.length - 1;
94
+ })();
72
95
  const preserveResponse = responseStarted
73
- && !isInternalRuntimeNotificationText(currentUserContent);
96
+ && !isInternalRuntimeNotificationText(currentUserContent)
97
+ && (producedOutput || !userCancelled);
74
98
  if (!preserveResponse) {
75
- // Null/unknown reasons keep the legacy rewind (status quo for wrapped
76
- // aborts without a closeReason enum); named non-user reasons preserve.
77
- const userCancelled = abortReason == null
78
- || USER_CANCEL_ABORT_REASONS.has(abortReason);
79
99
  if (!userCancelled) {
80
100
  return {
81
101
  messages,
@@ -101,6 +101,7 @@ export {
101
101
  // ── Session lifecycle / ask / crud / close / cleanup ──────────────────────
102
102
  export { SessionClosedError } from './manager/session-errors.mjs';
103
103
  export { setAgentRuntime } from './manager/agent-runtime-singleton.mjs';
104
+ export { prewarmAgentLoop } from './manager/runtime-loaders.mjs';
104
105
  export {
105
106
  createSession,
106
107
  updateSessionRoute,
@@ -120,6 +121,7 @@ export {
120
121
  listSessions,
121
122
  findSessionByScopeKey,
122
123
  clearSessionMessages,
124
+ rewindSessionMessagesTo,
123
125
  compactSessionMessages,
124
126
  updateSessionStatus,
125
127
  updateSessionGeneratedTitle,
@@ -96,3 +96,68 @@ export function isInformationalShellExitOne(result) {
96
96
  if (payload.startsWith('[stderr') || payload.includes('\n[stderr')) return false;
97
97
  return true;
98
98
  }
99
+
100
+ /**
101
+ * Shell-result outcome test. The shell tool reports failure through its OWN
102
+ * status markers (bash-tool.mjs `_shellFailureStatus`): `Error:
103
+ * [shell-run-failed] …`, `Error: [shell-tool-failed] …`, or a bare
104
+ * `[exit code: N]` / `[timeout: …]` / `[signal: …]` marker — optionally behind
105
+ * ⚠️ destructive-warning lines, which are prepended AFTER the error prefix is
106
+ * composed. A generic leading `Error:` is NOT a shell failure: it is just as
107
+ * likely the command's own stdout, which is why classifyResultKind (correct
108
+ * for tools that own the `Error:` convention) over-reports on shell output.
109
+ *
110
+ * @param {unknown} result
111
+ * @returns {boolean}
112
+ */
113
+ export function isShellFailureResult(result) {
114
+ if (typeof result !== 'string') return false;
115
+ const body = result.replace(/^(?:\s*⚠️[^\n]*\n)+/, '').trimStart();
116
+ if (/^error:\s*\[shell-(?:run|tool)-failed\]/i.test(body)) return true;
117
+ return /^\[(?:exit code:|timeout:|signal:)/i.test(body);
118
+ }
119
+
120
+ // Evidence that a command's OUTPUT reports a real failure. Used to separate a
121
+ // legitimate non-zero exit (a probe or report that prints its result and ends
122
+ // with 1) from a genuine failure that happens to write only to stdout — test
123
+ // runners, compilers and package managers all announce themselves in the text.
124
+ // Deliberately literal: only well-known failure banners count, so an unknown
125
+ // tool's report is never mislabelled a failure.
126
+ const SHELL_OUTPUT_FAILURE_EVIDENCE = [
127
+ /^not ok \d/m,
128
+ /^# fail [1-9]/m,
129
+ /\bAssertionError\b/,
130
+ /\bTraceback \(most recent call last\)/,
131
+ /^npm ERR!/m,
132
+ /\berror TS\d+\b/,
133
+ // Indented banners count too: runners print " FAIL <case>" under a header.
134
+ /^\s*FAIL(?:ED|URE)?\b/mi,
135
+ /\bFAILED[:!]/,
136
+ /^\s*✗/m,
137
+ /\b\d+ (?:tests? )?failed\b/i,
138
+ /^fatal:/m,
139
+ /^error:/mi,
140
+ /\bSyntaxError\b|\bReferenceError\b|\bTypeError\b/,
141
+ /\bCannot find module\b/,
142
+ ];
143
+ export function shellOutputReportsFailure(text) {
144
+ const body = String(text ?? '');
145
+ if (!body.trim()) return false;
146
+ return SHELL_OUTPUT_FAILURE_EVIDENCE.some((pattern) => pattern.test(body));
147
+ }
148
+
149
+ /**
150
+ * Classify a non-zero shell exit as a real failure or a legitimate report.
151
+ * Legitimate: exit code 1 exactly, no signal, NOTHING on stderr, real output
152
+ * on stdout, and no failure banner in that output. Everything else — any other
153
+ * exit code, a signal, any stderr, an empty capture, or a failure banner —
154
+ * stays a failure.
155
+ */
156
+ export function isLegitimateShellExit({ exitCode, signal, stdout, stderr } = {}) {
157
+ if (signal) return false;
158
+ if (exitCode !== 1) return false;
159
+ if (String(stderr ?? '').trim()) return false;
160
+ const out = String(stdout ?? '').trim();
161
+ if (!out || out === '(no output)') return false;
162
+ return !shellOutputReportsFailure(out);
163
+ }
@@ -5,13 +5,19 @@
5
5
  // pass), and unrecoverable errors throw. Behavior identical to the inline
6
6
  // try/catch it replaced.
7
7
  import { appendAgentTrace } from '../agent-trace.mjs';
8
- import { classifyError, isContextOverflowError } from '../providers/retry-classifier.mjs';
8
+ import {
9
+ classifyError,
10
+ isContextOverflowError,
11
+ isNonTerminalStreamClose,
12
+ jitterDelayMs,
13
+ } from '../providers/retry-classifier.mjs';
9
14
  import { setTimeout as sleepMs } from 'timers/promises';
10
15
  import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
11
16
  import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
12
17
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
13
18
  import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
14
19
  import { isOutputLimitStopReason } from './loop/termination.mjs';
20
+ import { isVisibleStreamProgress } from '../../../shared/stream-progress.mjs';
15
21
 
16
22
  function normalizedIncompleteUsage(raw) {
17
23
  if (!raw || typeof raw !== 'object') return undefined;
@@ -45,9 +51,35 @@ function normalizedIncompleteUsage(raw) {
45
51
  // the identical request is side-effect-free, so wait out the blip and retry
46
52
  // the send at the loop level. Two attempts, 5s/15s — combined with the
47
53
  // provider envelope this covers ~50s outages before failing honestly.
48
- const TRANSPORT_RETRY_BACKOFF_MS = Object.freeze([5_000, 15_000]);
54
+ // Env-overridable (comma-separated ms) so tests can drive the ladder without
55
+ // real waits and an operator can widen the window for a flaky uplink; the
56
+ // LENGTH of the list is the retry budget, exactly like codex's
57
+ // stream_max_retries + backoff table.
58
+ const TRANSPORT_RETRY_BACKOFF_MS = Object.freeze((() => {
59
+ const raw = process.env.MIXDOG_TRANSPORT_RETRY_BACKOFF_MS;
60
+ if (typeof raw === 'string' && raw.trim()) {
61
+ const parsed = raw.split(',')
62
+ .map((value) => Number(value.trim()))
63
+ .filter((value) => Number.isFinite(value) && value >= 0);
64
+ if (parsed.length) return parsed;
65
+ }
66
+ // Three steps (~50s of outage) instead of two: the provider envelope
67
+ // already covers short blips, so this ladder exists for the long ones.
68
+ // codex allows 5 stream reconnects and cc 10 request retries.
69
+ return [5_000, 15_000, 30_000];
70
+ })());
49
71
  export const TRANSPORT_RETRY_MAX = TRANSPORT_RETRY_BACKOFF_MS.length;
50
72
 
73
+ // codex jitters every backoff by 0.9–1.1 so parallel workers that lose the
74
+ // same uplink do not resume in lockstep; our provider layers already jitter
75
+ // (10% WS / 20% shared), this brings the loop ladder in line.
76
+ const TRANSPORT_RETRY_JITTER_RATIO = 0.1;
77
+
78
+ function transportRetryWaitMs(attemptIndex) {
79
+ const base = TRANSPORT_RETRY_BACKOFF_MS[attemptIndex];
80
+ return jitterDelayMs(base, TRANSPORT_RETRY_JITTER_RATIO);
81
+ }
82
+
51
83
  export async function sendWithRecovery(ctx) {
52
84
  const {
53
85
  provider, messages, model, sendTools, tools, opts,
@@ -56,7 +88,8 @@ export async function sendWithRecovery(ctx) {
56
88
  } = ctx;
57
89
  let response;
58
90
  // Bench-only turn timing (MIXDOG_TURN_TIMING=1): one stderr line per
59
- // provider request — TTFT (first stream delta) and total stream time.
91
+ // provider request — TTFT (first visible model progress) and total stream
92
+ // time. Transport/header and response-created acknowledgements do not count.
60
93
  // Inert unless the env flag is set; used to profile harness vs model
61
94
  // latency in Terminal-Bench runs.
62
95
  const turnT0 = process.env.MIXDOG_TURN_TIMING === '1' ? Date.now() : 0;
@@ -67,7 +100,7 @@ export async function sendWithRecovery(ctx) {
67
100
  timedOpts = {
68
101
  ...(opts || {}),
69
102
  onStreamDelta: (kind) => {
70
- if (!turnFirstDelta) turnFirstDelta = Date.now();
103
+ if (!turnFirstDelta && isVisibleStreamProgress(kind)) turnFirstDelta = Date.now();
71
104
  if (prevDelta) prevDelta(kind);
72
105
  },
73
106
  };
@@ -92,6 +125,22 @@ export async function sendWithRecovery(ctx) {
92
125
  // overflow-retry branch intentionally clears opts.onToolCall, and that
93
126
  // clear must survive the restore.
94
127
  const relayWitness = { textEmitted: false, toolCallsDispatched: 0 };
128
+ // Caller-visible reconnect progress for LOOP-level replays, mirroring the
129
+ // transports' own emitReconnectProgress (openai-oauth-ws). Without it the
130
+ // 5s/15s wait rendered as a frozen turn with no explanation, while codex
131
+ // surfaces "Reconnecting... n/max" through notify_stream_error and cc
132
+ // yields a SystemAPIErrorMessage for the same situation.
133
+ const emitLoopReconnectProgress = (attempt, waitMs, classifier) => {
134
+ try {
135
+ opts?.onStageChange?.('reconnecting', {
136
+ attempt,
137
+ max: TRANSPORT_RETRY_MAX,
138
+ classifier: classifier || null,
139
+ waitMs,
140
+ message: `Reconnecting... ${attempt}/${TRANSPORT_RETRY_MAX}`,
141
+ });
142
+ } catch { /* progress reporting must never break recovery */ }
143
+ };
95
144
  const prevOnTextDelta = typeof opts?.onTextDelta === 'function' ? opts.onTextDelta : null;
96
145
  const prevOnToolCall = typeof opts?.onToolCall === 'function' ? opts.onToolCall : null;
97
146
  const witnessedOnTextDelta = prevOnTextDelta
@@ -116,7 +165,15 @@ export async function sendWithRecovery(ctx) {
116
165
  }
117
166
  try {
118
167
  try {
119
- response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, timedOpts);
168
+ const admittedOpts = timedOpts?.admissionOwner
169
+ ? timedOpts
170
+ : { ...(timedOpts || {}), admissionOwner: sessionId };
171
+ response = await provider.send(
172
+ messages,
173
+ model,
174
+ sendTools.length ? sendTools : undefined,
175
+ admittedOpts,
176
+ );
120
177
  logTurnTiming('ok');
121
178
  } catch (sendErr) {
122
179
  logTurnTiming(`err:${sendErr?.code || sendErr?.name || 'unknown'}`);
@@ -220,7 +277,7 @@ export async function sendWithRecovery(ctx) {
220
277
  transportRetriesUsed < TRANSPORT_RETRY_MAX
221
278
  && await retractExposedTextForReplay()
222
279
  ) {
223
- const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
280
+ const waitMs = transportRetryWaitMs(transportRetriesUsed);
224
281
  try {
225
282
  process.stderr.write(
226
283
  `[loop] exposed-text stall retracted (sess=${sessionId || 'unknown'} `
@@ -238,6 +295,11 @@ export async function sendWithRecovery(ctx) {
238
295
  partialContentLen: sendErr.partialContent.length,
239
296
  });
240
297
  } catch { /* best-effort */ }
298
+ emitLoopReconnectProgress(
299
+ transportRetriesUsed + 1,
300
+ waitMs,
301
+ outcome.stallObserved ? 'stream_stalled' : 'stream_closed',
302
+ );
241
303
  await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
242
304
  return { action: 'retry_transport' };
243
305
  }
@@ -332,12 +394,23 @@ export async function sendWithRecovery(ctx) {
332
394
  && (
333
395
  (outcome.replaySafe === true && classifyError(sendErr) === 'transient')
334
396
  || (
335
- (classifyError(sendErr) === 'transient' || outcome.stallObserved === true)
397
+ // classifyError reports 'permanent' the moment anything
398
+ // was exposed, so an exposed disconnect could never
399
+ // reach the retraction path: name the transport
400
+ // symptoms directly (stall OR non-terminal close).
401
+ (classifyError(sendErr) === 'transient'
402
+ || outcome.stallObserved === true
403
+ // Same inversion for the truncated-stream class
404
+ // (anthropic message_start-without-message_stop,
405
+ // gemini/compat EOF): classifyError calls it
406
+ // 'transient' only while nothing was exposed.
407
+ || outcome.truncatedStream === true
408
+ || isNonTerminalStreamClose(sendErr))
336
409
  && await retractExposedTextForReplay()
337
410
  )
338
411
  )
339
412
  ) {
340
- const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
413
+ const waitMs = transportRetryWaitMs(transportRetriesUsed);
341
414
  try {
342
415
  process.stderr.write(
343
416
  `[loop] transient send failure with no observed output (sess=${sessionId || 'unknown'} `
@@ -356,6 +429,11 @@ export async function sendWithRecovery(ctx) {
356
429
  status: sendErr?.status ?? null,
357
430
  });
358
431
  } catch { /* best-effort */ }
432
+ emitLoopReconnectProgress(
433
+ transportRetriesUsed + 1,
434
+ waitMs,
435
+ sendErr?.retryClassifier || sendErr?.midstreamClassifier || sendErr?.code || null,
436
+ );
359
437
  await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
360
438
  return { action: 'retry_transport' };
361
439
  } else
@@ -121,7 +121,11 @@ export function _queueSessionSummaryUpsert(session, version = null) {
121
121
  */
122
122
  export function _queueSessionSummaryUpsertRow(row, version = null) {
123
123
  if (!row) return;
124
- _setCachedBaseSummary(row);
124
+ const fileProbe = probePath(join(getStoreDir(), `${row.id}.json`));
125
+ const durableRow = fileProbe.state === PROBE_PRESENT
126
+ ? { ...row, storageMtimeMs: fileProbe.mtimeMs, storageSize: fileProbe.size }
127
+ : row;
128
+ _setCachedBaseSummary(durableRow);
125
129
  if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
126
130
  _summaryCacheUpserts.delete(row.id);
127
131
  if (_summaryCacheLatestRows.get(row.id)?.version === version) {
@@ -130,7 +134,7 @@ export function _queueSessionSummaryUpsertRow(row, version = null) {
130
134
  _summaryCacheRemovals.delete(row.id);
131
135
  _invalidateSummaryRowsView();
132
136
  }
133
- _upsertSessionSummaryRow(row);
137
+ _upsertSessionSummaryRow(durableRow);
134
138
  }
135
139
 
136
140
  export function _queueSessionSummaryRemoval(id) {
@@ -220,7 +224,10 @@ export function _scanStoredSessionSummaryRows() {
220
224
  else markInvalid(f);
221
225
  continue;
222
226
  }
223
- const row = session ? _sessionSummary(session) : null;
227
+ const summary = session ? _sessionSummary(session) : null;
228
+ const row = summary
229
+ ? { ...summary, storageMtimeMs: fileProbe.mtimeMs, storageSize: fileProbe.size }
230
+ : null;
224
231
  _summaryScanCache.set(f, { mtimeMs: fileProbe.mtimeMs, size: fileProbe.size, row });
225
232
  changed = true;
226
233
  if (row) rows.push(row);
@@ -154,6 +154,8 @@ export function _sessionSummary(session) {
154
154
  preview: messageProjection.preview,
155
155
  generation: typeof session.generation === 'number' ? session.generation : 0,
156
156
  implicitBashSessionId: session.implicitBashSessionId || null,
157
+ storageMtimeMs: _positiveNumber(session.storageMtimeMs, 0),
158
+ storageSize: _positiveNumber(session.storageSize, 0),
157
159
  // Lifecycle provenance for catalog filters: resume-machinery scratch
158
160
  // forks (detachedReason='cli-resume') must never surface in Recent.
159
161
  detachedReason: session.detachedReason || null,
@@ -190,6 +192,8 @@ function _normalizeSummaryRow(row) {
190
192
  preview: _cleanPreview(row.preview || ''),
191
193
  generation: typeof row.generation === 'number' ? row.generation : 0,
192
194
  implicitBashSessionId: row.implicitBashSessionId || null,
195
+ storageMtimeMs: _positiveNumber(row.storageMtimeMs, 0),
196
+ storageSize: _positiveNumber(row.storageSize, 0),
193
197
  detachedReason: row.detachedReason || null,
194
198
  };
195
199
  }