mixdog 0.9.147 → 0.9.149

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 (265) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +5 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -26,6 +26,7 @@ import {
26
26
  SESSION_READ_ACTION_SET,
27
27
  } from './session-protocol.mjs';
28
28
  import { readSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
29
+ import { isPidAlive } from '../runtime/shared/pid-liveness.mjs';
29
30
  import { resolveRuntimeRoot } from '../runtime/shared/runtime-root.mjs';
30
31
 
31
32
  function runtimeRoot() {
@@ -86,6 +87,7 @@ const EVENT_STREAM_RECONNECT_MAX_MS = 30_000;
86
87
  const EVENT_STREAM_LIVENESS_TIMEOUT_MS = 45_000;
87
88
  const EVENT_STREAM_RECONNECT_BUDGET_MS = 10 * 60_000;
88
89
  const DEFAULT_DAEMON_READY_TIMEOUT_MS = 15_000;
90
+ const DEFAULT_DAEMON_UPGRADE_TIMEOUT_MS = 120_000;
89
91
  const DAEMON_OWNER_POLL_MS = 50;
90
92
  // A daemon that restarted needs a moment to rebind its port, so one immediate
91
93
  // re-attach often lands in the same gap the first call died in. Mirror the
@@ -146,13 +148,6 @@ export async function probeSessionHealth({ port, token, timeoutMs = 800 } = {})
146
148
  } catch { return null; }
147
149
  }
148
150
 
149
- function isPidAlive(pid) {
150
- const value = Number(pid);
151
- if (!Number.isInteger(value) || value <= 0) return false;
152
- try { process.kill(value, 0); return true; }
153
- catch (error) { return error?.code === 'EPERM'; }
154
- }
155
-
156
151
  export function readSessionDiscovery(discoveryPath = sessionDiscoveryPath()) {
157
152
  const readUnified = (candidate) => {
158
153
  const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
@@ -208,9 +203,15 @@ export function sessionDaemonCompatibility(health, {
208
203
 
209
204
  async function replaceLowerDaemon(discovery, initialHealth, { log }) {
210
205
  const configuredTimeoutMs = Number(process.env.MIXDOG_DAEMON_UPGRADE_TIMEOUT_MS);
211
- const deadline = configuredTimeoutMs > 0
212
- ? Date.now() + Math.max(10_000, configuredTimeoutMs)
213
- : Number.POSITIVE_INFINITY;
206
+ // A stuck older daemon (a worker that never finishes draining) must not hang
207
+ // every newer client forever: the wait is always bounded, and the caller
208
+ // surfaces `daemonUpgradePending` instead of blocking indefinitely.
209
+ // Infinity/NaN are NOT a configuration — they are the unbounded wait this
210
+ // deadline exists to prevent, so they fall back to the default.
211
+ const boundedTimeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0
212
+ ? Math.max(10_000, configuredTimeoutMs)
213
+ : DEFAULT_DAEMON_UPGRADE_TIMEOUT_MS;
214
+ const deadline = Date.now() + boundedTimeoutMs;
214
215
  const result = await request({
215
216
  port: discovery.port,
216
217
  token: discovery.token,
@@ -638,7 +639,12 @@ export async function attachSession({
638
639
  err.daemonTransportError = true;
639
640
  throw err;
640
641
  }
641
- if (out && out.error) throw new Error(out.error);
642
+ if (out && out.error) {
643
+ // Preserve the daemon's machine-readable classification across the wire.
644
+ const err = new Error(out.error);
645
+ if (out.code) err.code = String(out.code);
646
+ throw err;
647
+ }
642
648
  return out?.result;
643
649
  }
644
650
 
@@ -751,6 +757,13 @@ async function ensureSharedAttachment({ cwd, log }) {
751
757
  onFatal: (reason) => {
752
758
  log(`daemon attachment lost (${reason})`);
753
759
  if (shared === state) shared = null;
760
+ // The dead attachment still owns a server-side client record and its
761
+ // session subscriptions. Release it explicitly; otherwise every fatal
762
+ // re-attach cycle leaks one client (and one subscriber per session) on
763
+ // the daemon until its pid sweep runs.
764
+ void Promise.resolve()
765
+ .then(() => state.client?.close?.(`attachment lost: ${reason}`))
766
+ .catch((err) => log(`daemon attachment release failed: ${err?.message || err}`));
754
767
  for (const bucket of views.values()) {
755
768
  for (const view of [...bucket]) view.applyDetached(reason);
756
769
  }
@@ -872,8 +885,19 @@ export async function createSession(options = {}) {
872
885
  }
873
886
 
874
887
  let resyncing = false;
888
+ // A revision gap that lands while a read is already in flight is NOT covered
889
+ // by that read: it must re-run afterwards, otherwise the view stays stale
890
+ // forever behind a coalesced request.
891
+ let resyncDirty = false;
892
+ let resyncDirtyReason = '';
893
+ let resyncFailed = false;
875
894
  function resync(reason) {
876
- if (disposed || resyncing) return;
895
+ if (disposed) return;
896
+ if (resyncing) {
897
+ resyncDirty = true;
898
+ resyncDirtyReason = String(reason || 'revision gap');
899
+ return;
900
+ }
877
901
  resyncing = true;
878
902
  const requestAttachment = attachment;
879
903
  void requestAttachment.client.read({
@@ -885,10 +909,28 @@ export async function createSession(options = {}) {
885
909
  if (result && typeof result === 'object') resultAttachments.set(result, requestAttachment);
886
910
  if (!applyBody(result, requestAttachment) && result?.revision !== undefined) {
887
911
  log(`session ${sessionId} resync returned an unusable body`);
912
+ resyncDirty = true;
913
+ resyncFailed = true;
914
+ resyncDirtyReason = 'unusable resync body';
888
915
  }
889
916
  }).catch((err) => {
890
917
  log(`session ${sessionId} resync after ${reason} failed: ${err?.message || err}`);
891
- }).finally(() => { resyncing = false; });
918
+ resyncDirty = true;
919
+ resyncFailed = true;
920
+ resyncDirtyReason = `retry after failed resync (${reason})`;
921
+ }).finally(() => {
922
+ resyncing = false;
923
+ if (!resyncDirty || disposed) return;
924
+ resyncDirty = false;
925
+ const retryReason = resyncDirtyReason || 'revision gap';
926
+ resyncDirtyReason = '';
927
+ if (!resyncFailed) { resync(retryReason); return; }
928
+ // A failing daemon must not spin: retry the coalesced gap on a short
929
+ // fixed delay instead of a tight loop.
930
+ resyncFailed = false;
931
+ const timer = setTimeout(() => { if (!disposed) resync(retryReason); }, 250);
932
+ timer.unref?.();
933
+ });
892
934
  }
893
935
 
894
936
  let recoveryPromise = null;
@@ -1038,6 +1080,13 @@ export async function createSession(options = {}) {
1038
1080
  transitionChain = run.then(() => {}, () => {});
1039
1081
  return run;
1040
1082
  }
1083
+ // Mutations must not queue behind each other (ESC/abort stays immediate), but
1084
+ // they MUST observe the session a new/resume transition is rebinding to.
1085
+ // Joining the current tail of the transition chain — without extending it —
1086
+ // keeps a prompt from routing to the session that was just replaced.
1087
+ function afterPendingTransition() {
1088
+ return transitionChain;
1089
+ }
1041
1090
 
1042
1091
  function remoteCall(method, args, callOptions = {}) {
1043
1092
  if (disposed) return Promise.reject(new Error('This session view is disposed.'));
@@ -1047,21 +1096,28 @@ export async function createSession(options = {}) {
1047
1096
  ? 'session.configure'
1048
1097
  : null;
1049
1098
  if (!route) return Promise.reject(new TypeError(`session action ${method} is unavailable`));
1050
- const targetSessionId = sessionId;
1051
- const baseRevision = baseRevisionFor(attachment, targetSessionId);
1052
1099
  const stableCallId = typeof callOptions.callId === 'string' && callOptions.callId.trim()
1053
1100
  ? callOptions.callId.trim()
1054
1101
  : randomUUID();
1055
- return sendCall(route, {
1056
- sessionId: targetSessionId,
1057
- action: method,
1058
- args,
1059
- open: openParams,
1060
- baseRevision,
1061
- }, stableCallId).then(async (result) => {
1102
+ const dispatch = async () => {
1103
+ if (disposed) throw new Error('This session view is disposed.');
1104
+ const targetSessionId = sessionId;
1105
+ const baseRevision = baseRevisionFor(attachment, targetSessionId);
1106
+ const result = await sendCall(route, {
1107
+ sessionId: targetSessionId,
1108
+ action: method,
1109
+ args,
1110
+ open: openParams,
1111
+ baseRevision,
1112
+ }, stableCallId);
1062
1113
  if (!disposed && sessionId === targetSessionId) await applyResult(result, method);
1063
1114
  return result?.value ?? null;
1064
- });
1115
+ };
1116
+ // Reads stay immediate; a configure action is a mutation and waits out an
1117
+ // in-flight new/resume rebind.
1118
+ return route === 'session.configure'
1119
+ ? afterPendingTransition().then(dispatch)
1120
+ : dispatch();
1065
1121
  }
1066
1122
 
1067
1123
  async function rebindTo(result, previousSessionId) {
@@ -1122,6 +1178,9 @@ export async function createSession(options = {}) {
1122
1178
  if (disposed) throw new Error('This session view is disposed.');
1123
1179
  const submissionId = String(options?.id || '').trim()
1124
1180
  || `session-submit-${process.pid}-${Date.now()}-${(submitSeq += 1)}`;
1181
+ // Never address the session a new/resume transition is replacing.
1182
+ await afterPendingTransition();
1183
+ if (disposed) throw new Error('This session view is disposed.');
1125
1184
  const targetSessionId = sessionId;
1126
1185
  const baseRevision = baseRevisionFor(attachment, targetSessionId);
1127
1186
  const result = await sendCall('session.submit', {
@@ -1136,6 +1195,10 @@ export async function createSession(options = {}) {
1136
1195
  }
1137
1196
 
1138
1197
  async function abortAsync(options = {}) {
1198
+ if (disposed) return { aborted: false };
1199
+ // Abort skips the ordinary call queue but still targets the CURRENT
1200
+ // session: a rebind in flight owns the runtime the user is aborting.
1201
+ await afterPendingTransition();
1139
1202
  if (disposed) return { aborted: false };
1140
1203
  const result = await sendCall('session.abort', {
1141
1204
  sessionId, open: openParams, options,
@@ -64,7 +64,6 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
64
64
  'cancelMediaJob',
65
65
  'cancelOAuthProviderLogin',
66
66
  'checkForUpdate',
67
- 'claimRemote',
68
67
  'clear',
69
68
  'compact',
70
69
  'completeOnboarding',
@@ -148,7 +147,6 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
148
147
  'switchContext',
149
148
  'taskControl',
150
149
  'toggleFast',
151
- 'toggleRemote',
152
150
  'toggleVoice',
153
151
  'transcribeAudio',
154
152
  'updatePlugin',
@@ -72,15 +72,16 @@ test('an unhealthy runtime worker is replaced and every runtime is recovered', a
72
72
  const originalPid = host.status.worker.pid;
73
73
  assert.equal((await first.submitAsync('trigger')).pid, originalPid);
74
74
  await waitFor(
75
- () => host.status.worker.pid && host.status.worker.pid !== originalPid,
76
- 'replacement runtime worker',
75
+ () => host.status.worker.pid
76
+ && host.status.worker.pid !== originalPid
77
+ && logs.some((line) => /recycling.*unhealthy/.test(line))
78
+ && logs.some((line) => /recovered 2 runtime/.test(line)),
79
+ 'replacement runtime worker recovery',
77
80
  );
78
81
  const healthyFirst = await first.submitAsync('healthy');
79
82
  const healthySecond = await second.submitAsync('healthy');
80
83
  assert.notEqual(healthyFirst.pid, originalPid);
81
84
  assert.equal(healthySecond.pid, healthyFirst.pid);
82
- assert.ok(logs.some((line) => /unhealthy; recycling/.test(line)));
83
- assert.ok(logs.some((line) => /recovered 2 runtime/.test(line)));
84
85
  } finally {
85
86
  await host.close('test complete');
86
87
  await rm(dir, { recursive: true, force: true });
@@ -30,6 +30,24 @@ const RUNTIME_METHODS = new Set([
30
30
  'dispose',
31
31
  ]);
32
32
 
33
+ export function shouldRecycleSessionRuntimeWorker({
34
+ rssBytes = 0,
35
+ thresholdBytes = 0,
36
+ daemonBusy = 0,
37
+ pending = 0,
38
+ activeResources = 0,
39
+ now = Date.now(),
40
+ lastRecycleAt = 0,
41
+ cooldownMs = 0,
42
+ } = {}) {
43
+ return Number(thresholdBytes) > 0
44
+ && Number(rssBytes) >= Number(thresholdBytes)
45
+ && Number(daemonBusy) === 0
46
+ && Number(pending) === 0
47
+ && Number(activeResources) === 0
48
+ && Number(now) - Number(lastRecycleAt) >= Math.max(0, Number(cooldownMs) || 0);
49
+ }
50
+
33
51
  export function applyRuntimeStateFrame(previous, frame) {
34
52
  if (frame?.full && typeof frame.full === 'object') return frame.full;
35
53
  const patch = frame?.patch;
@@ -84,6 +102,10 @@ class SessionRuntimeWorker {
84
102
 
85
103
  onMessage(message, child = this.child) {
86
104
  if (!message || typeof message !== 'object') return;
105
+ // A superseded child (recycled, crashed, or replaced) can still flush
106
+ // queued frames after its replacement is live. Its late state/response must
107
+ // never outrank the replacement's revision or settle its pending calls.
108
+ if (child !== this.child) return;
87
109
  if (message.type === 'token-native-prewarm') {
88
110
  if (child === this.child) prewarmNativeTokenCounter();
89
111
  return;
@@ -162,6 +184,12 @@ class SessionRuntimeWorker {
162
184
  handleChildFailure(child, error) {
163
185
  if (this.failedChildren.has(child)) return;
164
186
  this.failedChildren.add(child);
187
+ // Same identity rule for failures: a retired child's late exit/error must
188
+ // not reject the replacement's in-flight calls or start a second recovery.
189
+ if (this.child && this.child !== child) {
190
+ this.log(`session runtime worker superseded child failed (ignored): ${error?.message || error}`);
191
+ return;
192
+ }
165
193
  if (this.child === child) this.child = null;
166
194
  // A dead child cannot spawn: its machine-budget slots return immediately.
167
195
  this.releaseAllSpawnLeases();
@@ -197,16 +225,29 @@ class SessionRuntimeWorker {
197
225
  return false;
198
226
  }
199
227
 
200
- recycleUnhealthy(child, detail = null) {
228
+ recycleChild(child, reason) {
201
229
  if (this.closed || child !== this.child || this.recycling) return;
202
- const reason = String(detail?.reason || 'session runtime worker reported unhealthy');
203
- this.log(`session runtime worker unhealthy; recycling pid=${child.pid || 'unknown'}: ${reason}`);
204
- this.recycling = this.stopChild(`unhealthy: ${reason}`)
230
+ this.log(`session runtime worker recycling pid=${child.pid || 'unknown'}: ${reason}`);
231
+ this.recycling = this.stopChild(reason)
232
+ .then(() => true)
205
233
  .catch((error) => {
206
- this.log(`session runtime worker unhealthy recycle failed: ${error?.message || error}`);
234
+ this.log(`session runtime worker recycle failed: ${error?.message || error}`);
207
235
  try { child.kill?.(); } catch {}
236
+ return false;
208
237
  })
209
238
  .finally(() => { this.recycling = null; });
239
+ return this.recycling;
240
+ }
241
+
242
+ recycleUnhealthy(child, detail = null) {
243
+ const reason = String(detail?.reason || 'session runtime worker reported unhealthy');
244
+ void this.recycleChild(child, `unhealthy: ${reason}`);
245
+ }
246
+
247
+ recycleForMemory(rssBytes) {
248
+ const rssMb = Math.round(Number(rssBytes) / 1024 / 1024);
249
+ return this.recycleChild(this.child, `idle memory reclaim (${rssMb} MB RSS)`)
250
+ || Promise.resolve(false);
210
251
  }
211
252
 
212
253
  requestRaw(type, payload = {}, timeoutMs = 10 * 60_000) {
@@ -331,9 +372,11 @@ class SessionRuntimeWorker {
331
372
  });
332
373
  try { await this.requestRaw('shutdown', { reason }, 8_000); } catch {}
333
374
  try { child.disconnect?.(); } catch {}
375
+ // The child drains its accepted-input writes before exiting; a hard kill
376
+ // inside that window is exactly what loses them.
334
377
  await Promise.race([
335
378
  exitPromise,
336
- new Promise((resolve) => setTimeout(resolve, 500)),
379
+ new Promise((resolve) => setTimeout(resolve, 2_000)),
337
380
  ]);
338
381
  if (!exited) {
339
382
  try { child.kill?.(); } catch {}
@@ -472,6 +515,7 @@ export function createSessionRuntimeHost({
472
515
  cwd = process.cwd(),
473
516
  env = process.env,
474
517
  log = () => {},
518
+ canRecycle = () => true,
475
519
  } = {}) {
476
520
  const worker = new SessionRuntimeWorker({
477
521
  workerEntry,
@@ -487,8 +531,23 @@ export function createSessionRuntimeHost({
487
531
 
488
532
  // Runtime workload telemetry is refreshed lazily with a short TTL.
489
533
  const WORKLOAD_TTL_MS = 2_000;
534
+ const configuredRecycleMb = Number(env.MIXDOG_SESSION_WORKER_RECYCLE_RSS_MB);
535
+ const MEMORY_RECYCLE_THRESHOLD_BYTES = (
536
+ Number.isFinite(configuredRecycleMb) && configuredRecycleMb >= 0
537
+ ? configuredRecycleMb
538
+ : 384
539
+ ) * 1024 * 1024;
540
+ const MEMORY_RECYCLE_INTERVAL_MS = Math.max(
541
+ 5_000,
542
+ Number(env.MIXDOG_SESSION_WORKER_RECYCLE_INTERVAL_MS) || 60_000,
543
+ );
544
+ const MEMORY_RECYCLE_COOLDOWN_MS = Math.max(
545
+ 60_000,
546
+ Number(env.MIXDOG_SESSION_WORKER_RECYCLE_COOLDOWN_MS) || 15 * 60_000,
547
+ );
490
548
  let workloadCache = { refreshedAt: 0, worker: null };
491
549
  let workloadRefresh = null;
550
+ let lastMemoryRecycleAt = Date.now();
492
551
  function refreshRuntimeWorkload() {
493
552
  if (workloadRefresh) return workloadRefresh;
494
553
  if (!worker.child || worker.child.killed) {
@@ -512,6 +571,36 @@ export function createSessionRuntimeHost({
512
571
  return workloadRefresh;
513
572
  }
514
573
 
574
+ async function maybeRecycleWorkerMemory() {
575
+ if (closed || !worker.child || worker.child.killed || worker.recycling || worker.recovery) return;
576
+ let daemonBusy = 1;
577
+ try { daemonBusy = Number(canRecycle()) === 1 ? 0 : 1; } catch {}
578
+ if (daemonBusy !== 0) return;
579
+ await refreshRuntimeWorkload();
580
+ const snapshot = workloadCache.worker;
581
+ const active = snapshot?.resources?.active || {};
582
+ const activeResources = Object.values(active)
583
+ .reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0);
584
+ const now = Date.now();
585
+ if (!shouldRecycleSessionRuntimeWorker({
586
+ rssBytes: snapshot?.memory?.rssBytes,
587
+ thresholdBytes: MEMORY_RECYCLE_THRESHOLD_BYTES,
588
+ daemonBusy,
589
+ pending: worker.pending.size,
590
+ activeResources,
591
+ now,
592
+ lastRecycleAt: lastMemoryRecycleAt,
593
+ cooldownMs: MEMORY_RECYCLE_COOLDOWN_MS,
594
+ })) return;
595
+ lastMemoryRecycleAt = now;
596
+ await worker.recycleForMemory(snapshot.memory.rssBytes);
597
+ workloadCache = { refreshedAt: Date.now(), worker: null };
598
+ }
599
+ const memoryRecycleTimer = MEMORY_RECYCLE_THRESHOLD_BYTES > 0
600
+ ? setInterval(() => { void maybeRecycleWorkerMemory(); }, MEMORY_RECYCLE_INTERVAL_MS)
601
+ : null;
602
+ memoryRecycleTimer?.unref?.();
603
+
515
604
  return {
516
605
  async create(options = {}) {
517
606
  if (closed) throw new Error('session runtime host is closed');
@@ -534,6 +623,7 @@ export function createSessionRuntimeHost({
534
623
  async close(reason = 'session runtime host closed') {
535
624
  if (closed) return;
536
625
  closed = true;
626
+ if (memoryRecycleTimer) clearInterval(memoryRecycleTimer);
537
627
  await worker.close(reason);
538
628
  },
539
629
  get status() {
@@ -541,6 +631,11 @@ export function createSessionRuntimeHost({
541
631
  return {
542
632
  active: Boolean(status.pid),
543
633
  worker: status,
634
+ memoryRecycle: {
635
+ thresholdBytes: MEMORY_RECYCLE_THRESHOLD_BYTES,
636
+ cooldownMs: MEMORY_RECYCLE_COOLDOWN_MS,
637
+ lastRecycleAt: lastMemoryRecycleAt,
638
+ },
544
639
  };
545
640
  },
546
641
  };
@@ -10,11 +10,7 @@ export async function disposeSessionRuntimeRecord(records, record, args = []) {
10
10
  } finally {
11
11
  try { record.unsubscribe?.(); } catch {}
12
12
  record.unsubscribe = null;
13
- record.source = null;
14
- record.projected = null;
15
- record.published = null;
16
- record.fields?.clear?.();
17
- record.items?.clear?.();
13
+ record.publishedSource = null;
18
14
  records?.delete?.(record.id);
19
15
  }
20
16
  }
@@ -27,10 +27,19 @@ let unhealthyReported = false;
27
27
  const pendingAbortPressureChecks = new WeakSet();
28
28
  const ABORT_PRESSURE_RETENTION_CHECK_MS = 30_000;
29
29
 
30
+ // Reporting is bound to DETECTION, not to a successful runtime call: an idle
31
+ // worker (or one whose every call fails) would otherwise never be recycled.
32
+ function reportUnhealthy() {
33
+ if (stopping || unhealthyReported || !unhealthyDetail) return;
34
+ unhealthyReported = true;
35
+ send({ type: 'unhealthy', detail: unhealthyDetail });
36
+ }
37
+
30
38
  process.on(SESSION_RUNTIME_WORKER_UNHEALTHY_EVENT, (detail) => {
31
39
  if (stopping || unhealthyDetail) return;
32
40
  unhealthyDetail = sanitizeForWire(detail) || { reason: 'session runtime worker unhealthy' };
33
41
  process.stderr.write(`[session-runtime-health] ${unhealthyDetail.reason || 'unhealthy'}\n`);
42
+ reportUnhealthy();
34
43
  });
35
44
 
36
45
  process.on('warning', (warning) => {
@@ -74,57 +83,21 @@ function errorBody(error) {
74
83
  };
75
84
  }
76
85
 
77
- function projectState(record, raw) {
78
- if (!raw || typeof raw !== 'object') return sanitizeForWire(raw);
79
- if (record.source === raw) return record.projected;
80
- const fields = record.fields;
81
- const items = record.items;
82
- const nextItems = new Map();
83
- const out = {};
84
- for (const [key, value] of Object.entries(raw)) {
85
- if (key === 'items' && Array.isArray(value)) {
86
- out.items = value.map((item) => {
87
- const cached = items.get(item);
88
- if (cached !== undefined) {
89
- nextItems.set(item, cached);
90
- return cached;
91
- }
92
- const cloned = sanitizeForWire(item);
93
- nextItems.set(item, cloned);
94
- return cloned;
95
- });
96
- continue;
97
- }
98
- const cached = fields.get(key);
99
- if (cached && cached.source === value) {
100
- out[key] = cached.value;
101
- continue;
102
- }
103
- const cloned = sanitizeForWire(value);
104
- if (cloned === undefined) continue;
105
- fields.set(key, { source: value, value: cloned });
106
- out[key] = cloned;
107
- }
108
- record.items = nextItems;
109
- record.source = raw;
110
- record.projected = out;
111
- return out;
112
- }
113
-
114
86
  function publish(record, forceFull = false) {
115
87
  if (!record || record.disposed) return;
116
- const projected = projectState(record, record.runtime.getState?.() || {});
117
- const previous = record.published;
118
- if (!forceFull && projected === previous) return;
119
- record.published = projected;
88
+ const source = record.runtime.getState?.() || {};
89
+ const previous = record.publishedSource;
90
+ if (!forceFull && source === previous) return;
91
+ const body = forceFull || !previous
92
+ ? { full: sanitizeForWire(source) }
93
+ : { patch: sanitizeForWire(diffSessionState(previous, source)) };
94
+ record.publishedSource = source;
120
95
  record.revision += 1;
121
96
  send({
122
97
  type: 'state',
123
98
  runtimeId: record.id,
124
99
  revision: record.revision,
125
- ...(forceFull || !previous
126
- ? { full: projected }
127
- : { patch: diffSessionState(previous, projected) }),
100
+ ...body,
128
101
  });
129
102
  }
130
103
 
@@ -136,11 +109,7 @@ async function createRuntime(message) {
136
109
  runtime,
137
110
  unsubscribe: null,
138
111
  disposed: false,
139
- source: null,
140
- projected: null,
141
- published: null,
142
- fields: new Map(),
143
- items: new Map(),
112
+ publishedSource: null,
144
113
  revision: 0,
145
114
  };
146
115
  records.set(record.id, record);
@@ -170,7 +139,6 @@ async function prewarm() {
170
139
  () => module.preloadSessionRuntimeModule?.(),
171
140
  () => module.preloadAgentLoopRuntime?.(),
172
141
  () => module.preloadKeychainSecrets?.(),
173
- () => module.preloadProviderRuntime?.(),
174
142
  () => module.preloadMemoryRuntime?.(),
175
143
  ].map((start) => Promise.resolve().then(start)));
176
144
  return { ready: true };
@@ -185,14 +153,42 @@ async function workloadSnapshot() {
185
153
  import('../runtime/shared/tool-workload-gates.mjs'),
186
154
  import('../runtime/shared/resource-admission.mjs'),
187
155
  ]);
156
+ const memory = process.memoryUsage();
188
157
  return {
189
158
  runtimes: records.size,
159
+ memory: {
160
+ rssBytes: memory.rss,
161
+ heapTotalBytes: memory.heapTotal,
162
+ heapUsedBytes: memory.heapUsed,
163
+ externalBytes: memory.external,
164
+ arrayBufferBytes: memory.arrayBuffers,
165
+ },
190
166
  childSpawns: gate.snapshot(),
191
167
  toolIo: workload.toolWorkloadSnapshot(),
192
168
  resources: resources.resourceAdmission.snapshot(),
193
169
  };
194
170
  }
195
171
 
172
+ // Accepted user input lives in the pending-message spool until its write
173
+ // settles. Exiting on top of an in-flight write silently loses it, so every
174
+ // exit path drains the spool first (bounded; a stuck write must not wedge
175
+ // shutdown).
176
+ async function exitAfterPendingWrites(code = 0) {
177
+ try {
178
+ const pendingMessages = await import(
179
+ '../runtime/agent/orchestrator/session/manager/pending-messages.mjs'
180
+ );
181
+ await pendingMessages.settlePendingMessageWrites?.({ timeoutMs: 1_500 });
182
+ } catch (error) {
183
+ try {
184
+ process.stderr.write(
185
+ `[session-runtime-worker] pending message drain failed: ${error?.message || error}\n`,
186
+ );
187
+ } catch {}
188
+ }
189
+ process.exit(code);
190
+ }
191
+
196
192
  async function stopAll(reason = 'session runtime worker shutdown') {
197
193
  if (stopping) return;
198
194
  stopping = true;
@@ -231,20 +227,19 @@ process.on('message', (message) => {
231
227
  throw new Error(`unknown session runtime message ${message.type}`);
232
228
  })().then((value) => {
233
229
  if (requestId) send({ type: 'response', requestId, ok: true, value: value ?? null });
234
- if (message.type === 'call' && unhealthyDetail && !unhealthyReported) {
235
- unhealthyReported = true;
236
- send({ type: 'unhealthy', detail: unhealthyDetail });
237
- }
238
- if (message.type === 'shutdown') setImmediate(() => process.exit(0));
230
+ reportUnhealthy();
231
+ if (message.type === 'shutdown') setImmediate(() => { void exitAfterPendingWrites(0); });
239
232
  }).catch((error) => {
240
233
  if (requestId) send({ type: 'response', requestId, ok: false, error: errorBody(error) });
234
+ reportUnhealthy();
241
235
  });
242
236
  });
243
237
 
244
238
  process.on('disconnect', () => {
245
- void stopAll('session runtime parent disconnected').finally(() => process.exit(0));
239
+ void stopAll('session runtime parent disconnected')
240
+ .finally(() => exitAfterPendingWrites(0));
246
241
  });
247
242
 
248
243
  process.on('SIGTERM', () => {
249
- void stopAll('session runtime SIGTERM').finally(() => process.exit(0));
244
+ void stopAll('session runtime SIGTERM').finally(() => exitAfterPendingWrites(0));
250
245
  });
@@ -318,7 +318,10 @@ export function createSessionService({
318
318
  // so identity is a sound cache key. Without this the whole transcript was
319
319
  // re-sanitized on every call AND every published frame.
320
320
  if (raw && entry.snapshotSource === raw) return entry.snapshotCache;
321
- const cloned = projectState(entry, raw);
321
+ // Runtime-worker IPC has already produced a wire-safe graph. Reusing it
322
+ // removes the daemon's second full transcript clone; in-process runtimes
323
+ // keep the sanitizer boundary below.
324
+ const cloned = entry.runtime?.isWireSafe === true ? raw : projectState(entry, raw);
322
325
  entry.snapshotSource = raw;
323
326
  entry.snapshotCache = cloned;
324
327
  return cloned;