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
@@ -204,34 +204,48 @@ export function createTagRegistry({
204
204
  if (id) removeWorkerRow({ sessionId: id });
205
205
  }
206
206
 
207
- function tombstoneTerminalSession(tag, sessionId, session = null) {
207
+ function tombstoneTerminalSession(tag, sessionId, session = null, expectedReapAt = '') {
208
208
  const value = clean(tag);
209
209
  const id = clean(sessionId);
210
210
  if (!value || !id) {
211
- forgetTerminalSession(value, id);
212
- return;
211
+ if (!expectedReapAt) forgetTerminalSession(value, id);
212
+ return false;
213
213
  }
214
- if (tags.get(value) === id) {
215
- tags.delete(value);
216
- tagAgents.delete(value);
217
- tagCwds.delete(value);
218
- }
219
- const tombstone = {
220
- tag: value,
221
- agent: clean(session?.agent) || null,
222
- cwd: clean(session?.cwd) || null,
223
- clientHostPid: positiveInt(session?.clientHostPid),
224
- reapedAt: new Date().toISOString(),
225
- };
214
+ let applied = false;
226
215
  flushWorkerIndexMutations();
227
216
  writeWorkerRows((byKey, tombstonesByKey, priorityTombstoneKeys) => {
228
- for (const [key, row] of [...byKey.entries()]) {
229
- if (clean(row.sessionId) === id) byKey.delete(key);
217
+ const matches = [...byKey.entries()]
218
+ .filter(([, row]) => clean(row.sessionId) === id);
219
+ const expected = expectedReapAt
220
+ ? matches.find(([, row]) =>
221
+ clean(row.reapAt) === expectedReapAt
222
+ && isTerminalWorkerStatus(row.status || row.stage))
223
+ : null;
224
+ // A stale timer from an earlier turn must never reap newer work.
225
+ if (expectedReapAt && !expected) return;
226
+ const source = expected?.[1] || matches[0]?.[1] || {};
227
+ const tombstone = {
228
+ tag: value,
229
+ agent: clean(session?.agent || source.agent) || null,
230
+ cwd: clean(session?.cwd || source.cwd) || null,
231
+ clientHostPid: positiveInt(session?.clientHostPid || source.clientHostPid),
232
+ reapedAt: new Date().toISOString(),
233
+ };
234
+ for (const [key] of matches) {
235
+ byKey.delete(key);
230
236
  }
231
237
  const tombstoneKey = tagTombstoneKey(tombstone);
232
238
  tombstonesByKey.set(tombstoneKey, tombstone);
233
239
  priorityTombstoneKeys.add(tombstoneKey);
240
+ applied = true;
234
241
  });
242
+ if (!applied) return false;
243
+ if (tags.get(value) === id) {
244
+ tags.delete(value);
245
+ tagAgents.delete(value);
246
+ tagCwds.delete(value);
247
+ }
248
+ return true;
235
249
  }
236
250
 
237
251
  function tagTombstoneForTag(tag, context = {}) {
@@ -256,27 +270,93 @@ export function createTagRegistry({
256
270
  return true;
257
271
  }
258
272
 
259
- function scheduleReap(sessionId, provider = null) {
260
- if (!sessionId) return;
273
+ function reapTerminalRow(row) {
274
+ const sessionId = clean(row?.sessionId);
275
+ const reapAt = clean(row?.reapAt);
276
+ const tag = clean(row?.tag) || tagForSession(sessionId);
277
+ if (!sessionId || !reapAt || !tag) return false;
278
+ const session = getLiveSession(sessionId);
279
+ if (!tombstoneTerminalSession(tag, sessionId, session, reapAt)) return false;
280
+ try { mgr.hideSessionFromList?.(sessionId); } catch {}
281
+ clearAgentStatuslineRoute(sessionId);
282
+ // Reaping expires only the reusable tag/runtime lease. The transcript is
283
+ // user-visible from the parent task's agent tab, so terminal cleanup must
284
+ // never tombstone it ahead of that parent.
285
+ try { mgr.unloadSessionRuntime?.(sessionId, 'terminal-reap'); } catch {}
286
+ return true;
287
+ }
288
+
289
+ function schedulePersistedReap(row) {
290
+ const sessionId = clean(row?.sessionId);
291
+ const reapAt = clean(row?.reapAt);
292
+ const deadline = Date.parse(reapAt) || 0;
293
+ if (!sessionId || !deadline) return false;
261
294
  cancelReap(sessionId);
262
- const reapProvider = provider || getLiveSession(sessionId)?.provider || null;
263
- const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), reapProvider);
264
- if (reapMs == null) return;
265
295
  const handle = setTimeout(() => {
266
296
  reapTimers.delete(sessionId);
267
- const session = getLiveSession(sessionId);
268
- try { mgr.hideSessionFromList?.(sessionId); } catch {}
269
- const tag = tagForSession(sessionId);
270
- tombstoneTerminalSession(tag, sessionId, session);
271
- clearAgentStatuslineRoute(sessionId);
272
- // Reaping expires only the reusable tag/runtime lease. The transcript is
273
- // user-visible from the parent task's agent tab, so terminal cleanup must
274
- // never tombstone it ahead of that parent. The normal completion path
275
- // unloads immediately; this delayed call is an idempotent race fallback.
276
- try { mgr.unloadSessionRuntime?.(sessionId, 'terminal-reap'); } catch {}
277
- }, reapMs);
297
+ reapTerminalRow(row);
298
+ }, Math.max(0, deadline - Date.now()));
278
299
  handle.unref?.();
279
300
  reapTimers.set(sessionId, handle);
301
+ return true;
302
+ }
303
+
304
+ function scheduleReap(sessionId, provider = null) {
305
+ const id = clean(sessionId);
306
+ if (!id) return false;
307
+ cancelReap(id);
308
+ // The terminal upsert is deferred to batch parallel fanout writes. Flush it
309
+ // before stamping the deadline so reapAt and the terminal state land in one
310
+ // durable lifecycle order.
311
+ flushWorkerIndexMutations();
312
+ const row = readWorkerRows().find((entry) => clean(entry.sessionId) === id);
313
+ if (!row || !isTerminalWorkerStatus(row.status || row.stage)) return false;
314
+ const reapProvider = clean(provider) || clean(row.provider)
315
+ || clean(getLiveSession(id)?.provider) || null;
316
+ const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), reapProvider);
317
+ const reapAt = reapMs == null ? null : new Date(Date.now() + reapMs).toISOString();
318
+ let persisted = null;
319
+ writeWorkerRows((byKey) => {
320
+ for (const [key, current] of byKey.entries()) {
321
+ if (clean(current.sessionId) !== id) continue;
322
+ if (!isTerminalWorkerStatus(current.status || current.stage)) return;
323
+ persisted = { ...current, reapAt };
324
+ byKey.set(key, persisted);
325
+ return;
326
+ }
327
+ });
328
+ return persisted?.reapAt ? schedulePersistedReap(persisted) : false;
329
+ }
330
+
331
+ /** Rebuild child terminal timers after a process restart. Legacy rows have
332
+ * no reapAt, so derive the original absolute deadline from their frozen
333
+ * terminal stamp instead of granting a fresh full lease on every boot. */
334
+ function recoverTerminalReaps() {
335
+ flushWorkerIndexMutations();
336
+ const now = Date.now();
337
+ const recovered = [];
338
+ writeWorkerRows((byKey) => {
339
+ for (const [key, row] of byKey.entries()) {
340
+ if (!isTerminalWorkerStatus(row.status || row.stage)) continue;
341
+ let reapAt = clean(row.reapAt);
342
+ if (!(Date.parse(reapAt) > 0)) {
343
+ const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), row.provider);
344
+ if (reapMs == null) continue;
345
+ const terminalAt = workerRowTime(row);
346
+ // A timestamp-free legacy row cannot prove that its grace already
347
+ // elapsed. Give it one normal window from recovery; timestamped rows
348
+ // keep their original completion-based deadline.
349
+ reapAt = new Date((terminalAt > 0 ? terminalAt : now) + reapMs).toISOString();
350
+ }
351
+ const next = { ...row, reapAt };
352
+ byKey.set(key, next);
353
+ recovered.push(next);
354
+ }
355
+ });
356
+ for (const row of recovered) {
357
+ if ((Date.parse(row.reapAt) || 0) <= now) reapTerminalRow(row);
358
+ else schedulePersistedReap(row);
359
+ }
280
360
  }
281
361
 
282
362
  function transitionStaleNonterminalRows(context = {}) {
@@ -321,6 +401,8 @@ export function createTagRegistry({
321
401
  return true;
322
402
  }
323
403
 
404
+ recoverTerminalReaps();
405
+
324
406
  return {
325
407
  readAllTagTombstones,
326
408
  readTagTombstones,
@@ -351,6 +433,7 @@ export function createTagRegistry({
351
433
  consumeTagTombstone,
352
434
  cancelReap,
353
435
  scheduleReap,
436
+ recoverTerminalReaps,
354
437
  transitionStaleNonterminalRows,
355
438
  };
356
439
  }
@@ -22,14 +22,14 @@ export const AGENT_TOOL = {
22
22
  openWorldHint: true,
23
23
  agentHidden: true,
24
24
  },
25
- description: 'Run scoped agent work as background tasks. spawn/send return task_id immediately; the same tag reuses its live session, while independent scopes need distinct tags. Wait for completion notifications; use status/read only for manual recovery.',
25
+ description: 'Run scoped agent work as background tasks. spawn/send return task_id immediately. Reuse one tag for one scope for its whole lifetime; give distinct tags only to independent scopes. Never mint a new tag because a session expired or was lost — same-tag spawn respawns it with the full brief. Wait for completion notifications; use status/read only for manual recovery.',
26
26
  inputSchema: {
27
27
  type: 'object',
28
28
  properties: {
29
29
  type: { type: 'string', enum: ['spawn', 'send', 'list', 'close', 'cancel', 'status', 'read', 'cleanup'], description: 'Required action. New spawn requires agent and one of prompt/message/file; send requires tag and one of prompt/message/file; read/status/close/cancel require task_id or tag; list/cleanup require no target.' },
30
30
  task_id: { type: 'string', description: 'Task ID returned by spawn/send; target for read/status/close/cancel.' },
31
31
  agent: { type: 'string', description: 'Workflow agent id. Required for a new spawn; same-tag reuse can inherit it.' },
32
- tag: { type: 'string', description: 'Stable scope handle. Reuse for the same scope; send requires it, while spawn can generate one when omitted.' },
32
+ tag: { type: 'string', description: 'Stable scope handle. Reuse the same tag for the same scope; never derive a variant by appending a number or suffix. send requires it, while spawn can generate one when omitted.' },
33
33
  prompt: { type: 'string', description: 'Spawn task brief; also accepted for send.' },
34
34
  message: { type: 'string', description: 'Send follow-up; also accepted for spawn.' },
35
35
  file: { type: 'string', description: 'Path to task text; alternative to prompt/message for spawn/send.' },
@@ -7,17 +7,14 @@ import { readFileSync, statSync } from 'node:fs';
7
7
  import { resolve } from 'node:path';
8
8
 
9
9
  import { updateJsonAtomicSync } from '../../runtime/shared/atomic-file.mjs';
10
- import { resolveAgentTerminalReapMs } from '../../session-runtime/config-helpers.mjs';
11
10
  import { WORKER_INDEX_FILE } from './tool-def.mjs';
12
11
  import { agentTagOf, clean, positiveInt, rowMatchesContext } from './helpers.mjs';
13
12
  import {
14
13
  applyWorkerRowUpsert,
15
- isDeadWorkerStatus,
16
14
  isLeadPoolAgent,
17
15
  normalizeTagTombstones,
18
16
  tagTombstoneKey,
19
17
  workerRowKey,
20
- workerRowTime,
21
18
  } from './worker-rows.mjs';
22
19
 
23
20
  export function createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCwds }) {
@@ -33,15 +30,11 @@ export function createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCw
33
30
  return dataDir ? resolve(dataDir, WORKER_INDEX_FILE) : null;
34
31
  }
35
32
 
36
- // A terminal row ages out after the configured reap window so the index does
37
- // not grow without bound; live rows are always kept.
33
+ // Reaping is an explicit lifecycle mutation owned by tag-registry. Silently
34
+ // filtering an expired row here would skip its tombstone and make persisted
35
+ // state disagree with the reusable-tag lease.
38
36
  function keepWorkerRow(row = {}) {
39
- if (!clean(row.tag) || !clean(row.sessionId)) return false;
40
- const t = workerRowTime(row);
41
- if (!t) return true;
42
- if (!isDeadWorkerStatus(row.status || row.stage)) return true;
43
- const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), row.provider);
44
- return reapMs == null || Date.now() - t < reapMs;
37
+ return Boolean(clean(row.tag) && clean(row.sessionId));
45
38
  }
46
39
 
47
40
  function normalizeWorkerRows(value) {
@@ -69,6 +62,7 @@ export function createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCw
69
62
  lastUsedAt: clean(row.lastUsedAt) || null,
70
63
  finishedAt: clean(row.finishedAt) || null,
71
64
  turnStartedAt: clean(row.turnStartedAt) || null,
65
+ reapAt: clean(row.reapAt) || null,
72
66
  clientHostPid: positiveInt(row.clientHostPid),
73
67
  cwd: clean(row.cwd) || null,
74
68
  task_id: clean(row.task_id || row.taskId) || null,
@@ -221,6 +215,9 @@ export function createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCw
221
215
  // Turn dispatch stamps this; terminal upserts leave it null and the
222
216
  // merge in applyWorkerRowUpsert preserves the running turn's value.
223
217
  turnStartedAt: clean(extra.turnStartedAt) || null,
218
+ // Active/new work clears the previous lease. Terminal settlement writes
219
+ // the new absolute deadline immediately afterwards via scheduleReap().
220
+ reapAt: clean(extra.reapAt) || null,
224
221
  clientHostPid: positiveInt(extra.clientHostPid) || positiveInt(session?.clientHostPid),
225
222
  cwd: clean(session?.cwd) || clean(extra.cwd) || null,
226
223
  task_id: clean(extra.task_id || extra.taskId) || null,
@@ -21,7 +21,9 @@ export function workerRowTime(row = {}) {
21
21
  }
22
22
 
23
23
  export function isTerminalWorkerStatus(status) {
24
- return /^(idle|closed|completed|failed|error|cancelled|canceled|killed|timeout)$/i.test(clean(status));
24
+ return /^(idle|closed|complete|completed|done|success|fail|failed|error|cancelled|canceled|killed|timeout)$/i.test(
25
+ clean(status),
26
+ );
25
27
  }
26
28
 
27
29
  /** Finished / gone. Idle is living and must stay in the process-global pool. */
@@ -100,6 +102,7 @@ export function workerRowToSession(row = {}) {
100
102
  createdAt: row.createdAt || null,
101
103
  updatedAt: row.updatedAt || null,
102
104
  lastUsedAt: row.lastUsedAt || null,
105
+ reapAt: row.reapAt || null,
103
106
  clientHostPid: row.clientHostPid || null,
104
107
  cwd: row.cwd || null,
105
108
  permission: row.permission || null,
@@ -5,7 +5,6 @@ import {
5
5
  cleanupBackgroundTasks,
6
6
  getBackgroundTask,
7
7
  listBackgroundTasks,
8
- reconcileBackgroundTask,
9
8
  startBackgroundTask,
10
9
  sanitizeTaskMeta,
11
10
  taskIdFromArgs,
@@ -51,6 +50,12 @@ import { createProviderInit } from './agent-tool/provider-init.mjs';
51
50
  import { createNotify } from './agent-tool/notify.mjs';
52
51
  import { createTagRegistry } from './agent-tool/tag-registry.mjs';
53
52
  import { createJobViews } from './agent-tool/job-views.mjs';
53
+ import {
54
+ reconcileJobFinally,
55
+ reconcileJobStreamStalled,
56
+ reconcileJobTerminalResult,
57
+ reconcileJobWatchdogPartial,
58
+ } from './agent-tool/job-task-reconcile.mjs';
54
59
  import { createSpawnFlow } from './agent-tool/spawn-flow.mjs';
55
60
  import { resolveAgentSpawnPreset } from './agent-tool/spawn-preset.mjs';
56
61
  import {
@@ -203,6 +208,7 @@ export function createStandaloneAgent({
203
208
  progressWatchdogs,
204
209
  startProgressIdleWatchdog,
205
210
  turnStartStamper,
211
+ progressStamper,
206
212
  prepareSpawn,
207
213
  prepareSpawnInProcess,
208
214
  runSpawn,
@@ -259,6 +265,7 @@ export function createStandaloneAgent({
259
265
  // the watchdog sweep re-stamps turnStartedAt at each turn boundary.
260
266
  const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent), sendAgent, {
261
267
  onTurnStart: turnStartStamper(session, tag),
268
+ onProgress: progressStamper(session, tag),
262
269
  });
263
270
  const turnReview = createTurnReviewCollector(session, tag, sendAgent, notifyContext || {});
264
271
  let finalStatus = 'idle';
@@ -294,24 +301,7 @@ export function createStandaloneAgent({
294
301
  const value = completionValue(terminalResult);
295
302
  if (job) job._terminalResultValue = value;
296
303
  notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
297
- // Mark terminal as soon as the worker's final result lands so a slow
298
- // post-result save can't strand the task in `running`. Idempotent.
299
- if (job?.taskId) {
300
- try {
301
- reconcileBackgroundTask(job.taskId, value.error
302
- ? {
303
- status: 'failed',
304
- result: value,
305
- error: value.error,
306
- terminalReason: 'agent-empty-final',
307
- }
308
- : {
309
- status: 'completed',
310
- result: value,
311
- terminalReason: 'agent-terminal-result',
312
- });
313
- } catch {}
314
- }
304
+ reconcileJobTerminalResult(job, value);
315
305
  },
316
306
  } : {}),
317
307
  });
@@ -338,30 +328,11 @@ export function createStandaloneAgent({
338
328
  stallAbort: true,
339
329
  };
340
330
  if (job) job._terminalResultValue = value;
341
- if (job?.taskId) {
342
- try {
343
- reconcileBackgroundTask(job.taskId, {
344
- status: 'completed',
345
- result: value,
346
- terminalReason: 'agent-watchdog-partial',
347
- });
348
- } catch {}
349
- }
331
+ reconcileJobWatchdogPartial(job, value);
350
332
  return value;
351
333
  }
352
334
  finalStatus = 'error';
353
- // Part C (send path mirror): a mid-stream stall throws with no terminal
354
- // result — reconcile to `failed` so the owner is notified rather than the
355
- // task hanging in `running`. Idempotent (see runSpawn note).
356
- if (job?.taskId && job._terminalResultValue === undefined) {
357
- try {
358
- reconcileBackgroundTask(job.taskId, {
359
- status: 'failed',
360
- error,
361
- terminalReason: 'agent-stream-stalled',
362
- });
363
- } catch {}
364
- }
335
+ reconcileJobStreamStalled(job, error);
365
336
  throw error;
366
337
  } finally {
367
338
  turnReview.complete();
@@ -371,20 +342,7 @@ export function createStandaloneAgent({
371
342
  stage: finalStatus,
372
343
  finishedAt: new Date().toISOString(),
373
344
  });
374
- // Safety net mirror of runSpawn: reconcile a stranded task if a post-result
375
- // step hung/threw after a terminal result was already produced.
376
- if (job && job._terminalResultValue !== undefined) {
377
- try {
378
- reconcileBackgroundTask(job.taskId, {
379
- status: finalStatus === 'error' ? 'failed' : 'completed',
380
- result: job._terminalResultValue,
381
- ...(finalStatus === 'error' && job._terminalResultValue?.error
382
- ? { error: job._terminalResultValue.error }
383
- : {}),
384
- terminalReason: 'agent-finally-reconcile',
385
- });
386
- } catch {}
387
- }
345
+ reconcileJobFinally(job, finalStatus);
388
346
  scheduleReap(sessionId);
389
347
  // Same lifecycle as a fresh spawn: the transcript/tag stays resumable,
390
348
  // while heavy process-local runtime state is reclaimed immediately.
@@ -458,7 +416,6 @@ export function createStandaloneAgent({
458
416
  }
459
417
  cancelReap(sessionId);
460
418
  const tag = tagForSession(sessionId);
461
- forgetTerminalSession(tag, sessionId);
462
419
  clearAgentStatuslineRoute(sessionId);
463
420
  // Cancel any running background task bound to this session BEFORE closing
464
421
  // the session. Otherwise closeSession rejects the in-flight runSpawn with
@@ -470,8 +427,16 @@ export function createStandaloneAgent({
470
427
  if (row.sessionId !== sessionId && row.tag !== target) continue;
471
428
  cancelBackgroundTask(row.task_id, 'cancelled by agent close');
472
429
  }
430
+ // Close (and stamp cancelStatus) BEFORE dropping the worker row. Removing
431
+ // the row first left the next pool read with only a leftover heartbeat,
432
+ // which republished the session as `running`.
473
433
  const ok = mgr.closeSession(sessionId, 'cli-agent-close');
474
434
  if (task?.taskId) cancelBackgroundTask(task.taskId, 'cancelled by agent close');
435
+ // Drop the row only once the cancellation is DURABLY recorded. A close that
436
+ // failed still forgot the row, leaving the next pool read with a fresh
437
+ // heartbeat and no cancelled state — which republished the session as
438
+ // `running`, the exact lingering row this whole path exists to prevent.
439
+ if (ok) forgetTerminalSession(tag, sessionId);
475
440
  return { closed: ok, tag, sessionId, task_id: task?.taskId || null };
476
441
  }
477
442
 
@@ -41,13 +41,32 @@ export function createProgressWatchdogRegistry({ mgr }) {
41
41
  state.lastAskStartedAt = askStartedAt;
42
42
  try { state.onTurnStart?.(askStartedAt); } catch { /* bookkeeping only */ }
43
43
  }
44
- const abortErr = snapshot ? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy) : null;
45
44
  const sess = typeof mgr.getSession === 'function' ? mgr.getSession(sessionId) : null;
46
45
  const iteration = typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null;
46
+ // In-turn progress. The worker index is otherwise stamped only at spawn and
47
+ // at turn boundaries, so a FIRST turn that runs dozens of tool iterations
48
+ // leaves the Agents panel frozen on its start stamp for minutes (user
49
+ // report: 위임한 세션이 안 도는 것처럼 보인다). This sweep already holds the
50
+ // live session, so reporting the change costs no extra read.
51
+ if (state.onProgress) {
52
+ const messages = Array.isArray(sess?.messages) ? sess.messages.length : 0;
53
+ const signature = `${iteration ?? ''}:${messages}`;
54
+ if (signature !== state.lastProgressSignature) {
55
+ state.lastProgressSignature = signature;
56
+ try { state.onProgress({ iteration, messages }); } catch { /* bookkeeping only */ }
57
+ }
58
+ }
59
+ const abortErr = snapshot ? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy) : null;
47
60
  // No progress snapshot: fall back to the coarse last-progress timestamp so a
48
61
  // session that never reports structured progress can still stall out.
49
62
  if (!abortErr && !snapshot) {
50
- const reported = mgr.getSessionLastProgressAt(sessionId);
63
+ // start() admits a manager that provides EITHER progress source, so this
64
+ // fallback must not assume the coarse one exists: throwing here escaped
65
+ // the shared interval and disabled watchdog coverage for every session.
66
+ let reported = 0;
67
+ if (typeof mgr.getSessionLastProgressAt === 'function') {
68
+ try { reported = Number(mgr.getSessionLastProgressAt(sessionId)) || 0; } catch { reported = 0; }
69
+ }
51
70
  const last = reported || anchorTs;
52
71
  if (watchdogPolicy.idleStaleMs > 0 && now - last > watchdogPolicy.idleStaleMs) {
53
72
  abortAgentProgressWatchdog(controller, {
@@ -80,7 +99,10 @@ export function createProgressWatchdogRegistry({ mgr }) {
80
99
  function ensureTimer() {
81
100
  if (timer || watched.size === 0) return;
82
101
  timer = setInterval(() => {
83
- for (const state of [...watched.values()]) check(state);
102
+ // One session's failure must never cancel the sweep for the others.
103
+ for (const state of [...watched.values()]) {
104
+ try { check(state); } catch { /* watchdog is best-effort */ }
105
+ }
84
106
  }, WATCHDOG_SWEEP_INTERVAL_MS);
85
107
  timer.unref?.();
86
108
  }
@@ -88,7 +110,7 @@ export function createProgressWatchdogRegistry({ mgr }) {
88
110
  return {
89
111
  /** Watch a session until the returned handle is stopped; null when the
90
112
  * policy is off or the manager cannot link an abort signal. */
91
- start(sessionId, watchdogPolicy, agent = null, { onTurnStart = null } = {}) {
113
+ start(sessionId, watchdogPolicy, agent = null, { onTurnStart = null, onProgress = null } = {}) {
92
114
  if (!sessionId || !agentWatchdogPolicyActive(watchdogPolicy)) return null;
93
115
  if (typeof mgr.getSessionProgressSnapshot !== 'function'
94
116
  && typeof mgr.getSessionLastProgressAt !== 'function') return null;
@@ -103,7 +125,9 @@ export function createProgressWatchdogRegistry({ mgr }) {
103
125
  controller,
104
126
  anchorTs,
105
127
  onTurnStart: typeof onTurnStart === 'function' ? onTurnStart : null,
128
+ onProgress: typeof onProgress === 'function' ? onProgress : null,
106
129
  lastAskStartedAt: 0,
130
+ lastProgressSignature: '',
107
131
  };
108
132
  watched.set(sessionId, state);
109
133
  ensureTimer();
@@ -8,24 +8,13 @@
8
8
  import http from 'node:http';
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { readFileSync } from 'node:fs';
11
-
12
- function parsePid(value) {
13
- const n = Number(value);
14
- return Number.isInteger(n) && n > 0 ? n : null;
15
- }
11
+ import { isPidAlive, parsePid } from '../runtime/shared/pid-liveness.mjs';
16
12
 
17
13
  function parsePort(value) {
18
14
  const n = Number(value);
19
15
  return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
20
16
  }
21
17
 
22
- function isPidAlive(pid) {
23
- const n = parsePid(pid);
24
- if (!n) return false;
25
- try { process.kill(n, 0); return true; }
26
- catch (error) { return error?.code === 'EPERM'; }
27
- }
28
-
29
18
  // Read + validate the discovery file. Returns null when missing/corrupt or when
30
19
  // the recorded pid is dead (stale daemon) so the caller reclaims + respawns.
31
20
  export function readChannelDiscovery(discoveryPath) {
@@ -153,6 +142,11 @@ export async function attachChannel({
153
142
  if (!isExpectedDaemon(initialHealth)) throw staleDiscoveryError('daemon discovery pid does not match health');
154
143
 
155
144
  let reg;
145
+ // A stable registration id even on the FIRST attach: if this response is
146
+ // lost, the daemon reaps the never-acknowledged token through its replay TTL
147
+ // instead of holding an unreapable client against a live pid forever (which
148
+ // blocks daemon self-shutdown).
149
+ const initialRegistrationId = randomUUID();
156
150
  try {
157
151
  reg = await request({
158
152
  port,
@@ -161,7 +155,10 @@ export async function attachChannel({
161
155
  path: '/client/register',
162
156
  // Channel attachment observes the current owner and never claims the
163
157
  // manual routing seat merely by opening the transport.
164
- body: { leadPid, cwd, passive: true, restoreSessionId },
158
+ body: {
159
+ leadPid, cwd, passive: true, restoreSessionId,
160
+ registrationId: initialRegistrationId,
161
+ },
165
162
  timeoutMs: 3000,
166
163
  agent: controlAgent,
167
164
  });
@@ -186,7 +183,12 @@ export async function attachChannel({
186
183
  let reconnectReplaceToken = null;
187
184
  let lifecycle = 0;
188
185
  let stableTimer = null;
186
+ let livenessTimer = null;
189
187
  let closePromise = null;
188
+ // The daemon writes a `: ka` comment every 15s. A half-open socket delivers
189
+ // no bytes and no FIN, so without this deadline the stream stays "connected"
190
+ // forever and the client never reconnects.
191
+ const STREAM_LIVENESS_MS = 45_000;
190
192
  // Bounded reconnect is only for a verified-live daemon's transient SSE loss.
191
193
  // A stale/dead endpoint signals onFatal immediately so the owner re-reads
192
194
  // discovery instead of spinning against the captured port.
@@ -220,6 +222,10 @@ export async function attachChannel({
220
222
  if (stableTimer) { try { clearTimeout(stableTimer); } catch {} stableTimer = null; }
221
223
  }
222
224
 
225
+ function clearLivenessTimer() {
226
+ if (livenessTimer) { try { clearTimeout(livenessTimer); } catch {} livenessTimer = null; }
227
+ }
228
+
223
229
  function signalFatal(reason) {
224
230
  if (closed || fatal) return;
225
231
  fatal = true;
@@ -227,6 +233,7 @@ export async function attachChannel({
227
233
  lifecycle++;
228
234
  if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
229
235
  clearStableTimer();
236
+ clearLivenessTimer();
230
237
  try { sseReq?.destroy?.(); } catch {}
231
238
  log(`sse stale endpoint (${reason}); signalling re-attach`);
232
239
  try { onFatal(reason); } catch {}
@@ -251,6 +258,9 @@ export async function attachChannel({
251
258
 
252
259
  function openStream() {
253
260
  if (closed) return;
261
+ // A liveness expiry is a transient stream loss (reconnect), not a dead
262
+ // endpoint: keep the destroy() it triggers off the fatal path.
263
+ let livenessLost = false;
254
264
  const req = http.request({
255
265
  hostname: '127.0.0.1',
256
266
  port,
@@ -278,9 +288,22 @@ export async function attachChannel({
278
288
  if (!closed && !fatal && req === sseReq) reconnectAttempts = 0;
279
289
  }, STABLE_STREAM_MS);
280
290
  stableTimer.unref?.();
291
+ const armLiveness = () => {
292
+ clearLivenessTimer();
293
+ livenessTimer = setTimeout(() => {
294
+ if (closed || fatal || req !== sseReq) return;
295
+ livenessLost = true;
296
+ handleStreamLoss(`sse liveness timeout after ${STREAM_LIVENESS_MS}ms`);
297
+ try { req.destroy(new Error('channel SSE liveness timeout')); } catch {}
298
+ }, STREAM_LIVENESS_MS);
299
+ livenessTimer.unref?.();
300
+ };
301
+ armLiveness();
281
302
  let buf = '';
282
303
  res.on('data', (chunk) => {
283
304
  if (req !== sseReq || closed) return;
305
+ // Any byte, including a `: ka` keepalive comment, proves liveness.
306
+ armLiveness();
284
307
  buf += chunk;
285
308
  let idx;
286
309
  while ((idx = buf.indexOf('\n\n')) >= 0) {
@@ -299,18 +322,21 @@ export async function attachChannel({
299
322
  res.on('end', () => {
300
323
  if (req === sseReq) {
301
324
  clearStableTimer();
325
+ clearLivenessTimer();
302
326
  handleStreamLoss('sse ended');
303
327
  }
304
328
  });
305
329
  res.on('error', () => {
306
330
  if (req === sseReq) {
307
331
  clearStableTimer();
332
+ clearLivenessTimer();
308
333
  handleStreamLoss('sse error');
309
334
  }
310
335
  });
311
336
  });
312
337
  req.on('error', () => {
313
- if (req === sseReq) signalFatal('sse req error');
338
+ if (req !== sseReq || livenessLost) return;
339
+ signalFatal('sse req error');
314
340
  });
315
341
  sseReq = req;
316
342
  req.end();
@@ -384,7 +410,12 @@ export async function attachChannel({
384
410
  err.daemonTransportError = true;
385
411
  throw err;
386
412
  }
387
- if (out && out.error) throw new Error(out.error);
413
+ if (out && out.error) {
414
+ // Preserve the daemon's machine-readable classification across the wire.
415
+ const err = new Error(out.error);
416
+ if (out.code) err.code = String(out.code);
417
+ throw err;
418
+ }
388
419
  return out?.result;
389
420
  }
390
421
 
@@ -394,6 +425,7 @@ export async function attachChannel({
394
425
  lifecycle++;
395
426
  if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
396
427
  clearStableTimer();
428
+ clearLivenessTimer();
397
429
  try { sseReq?.destroy?.(); } catch {}
398
430
  // A re-register already in flight can mint a fresh token after close().
399
431
  // Await it so its stale branch deregisters that token before close resolves.