@pentoshi/clai 3.9.2 → 3.9.4

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 (407) hide show
  1. package/dist/agent/compaction-attempt.d.ts +22 -0
  2. package/dist/agent/compaction-attempt.js +69 -0
  3. package/dist/agent/compaction-attempt.js.map +1 -0
  4. package/dist/agent/compaction-summary.d.ts +27 -0
  5. package/dist/agent/compaction-summary.js +62 -0
  6. package/dist/agent/compaction-summary.js.map +1 -1
  7. package/dist/agent/context-breakdown.d.ts +2 -0
  8. package/dist/agent/context-breakdown.js +37 -0
  9. package/dist/agent/context-breakdown.js.map +1 -1
  10. package/dist/agent/context-manager.d.ts +7 -1
  11. package/dist/agent/context-manager.js +64 -15
  12. package/dist/agent/context-manager.js.map +1 -1
  13. package/dist/agent/continue-orient.d.ts +8 -0
  14. package/dist/agent/continue-orient.js +26 -3
  15. package/dist/agent/continue-orient.js.map +1 -1
  16. package/dist/agent/durable-envelope.d.ts +39 -0
  17. package/dist/agent/durable-envelope.js +195 -0
  18. package/dist/agent/durable-envelope.js.map +1 -0
  19. package/dist/agent/events.d.ts +21 -0
  20. package/dist/agent/loop-guard.d.ts +13 -5
  21. package/dist/agent/loop-guard.js +114 -1
  22. package/dist/agent/loop-guard.js.map +1 -1
  23. package/dist/agent/outcomes.d.ts +29 -0
  24. package/dist/agent/outcomes.js +118 -3
  25. package/dist/agent/outcomes.js.map +1 -1
  26. package/dist/agent/plan-decision.js +8 -3
  27. package/dist/agent/plan-decision.js.map +1 -1
  28. package/dist/agent/plan-tool.d.ts +5 -0
  29. package/dist/agent/plan-tool.js +166 -6
  30. package/dist/agent/plan-tool.js.map +1 -1
  31. package/dist/agent/reliability-policy.d.ts +11 -6
  32. package/dist/agent/reliability-policy.js +19 -13
  33. package/dist/agent/reliability-policy.js.map +1 -1
  34. package/dist/agent/request-budget.d.ts +24 -0
  35. package/dist/agent/request-budget.js +59 -0
  36. package/dist/agent/request-budget.js.map +1 -0
  37. package/dist/agent/responder-context.js +2 -2
  38. package/dist/agent/responder-context.js.map +1 -1
  39. package/dist/agent/responder-parent.d.ts +30 -0
  40. package/dist/agent/responder-parent.js +82 -0
  41. package/dist/agent/responder-parent.js.map +1 -0
  42. package/dist/agent/runner.d.ts +15 -2
  43. package/dist/agent/runner.js +766 -213
  44. package/dist/agent/runner.js.map +1 -1
  45. package/dist/agent/scope-context.d.ts +5 -0
  46. package/dist/agent/scope-context.js +7 -1
  47. package/dist/agent/scope-context.js.map +1 -1
  48. package/dist/agent/session-policy.js +1 -0
  49. package/dist/agent/session-policy.js.map +1 -1
  50. package/dist/agent/stop-summary.js +13 -7
  51. package/dist/agent/stop-summary.js.map +1 -1
  52. package/dist/agent/task-analyzer.js +7 -3
  53. package/dist/agent/task-analyzer.js.map +1 -1
  54. package/dist/agent/task-evidence.js +2 -0
  55. package/dist/agent/task-evidence.js.map +1 -1
  56. package/dist/agent/task-sync.d.ts +11 -0
  57. package/dist/agent/task-sync.js +31 -0
  58. package/dist/agent/task-sync.js.map +1 -1
  59. package/dist/agent/tool-call-parser.d.ts +6 -13
  60. package/dist/agent/tool-call-parser.js +186 -82
  61. package/dist/agent/tool-call-parser.js.map +1 -1
  62. package/dist/agent/tool-history.d.ts +8 -2
  63. package/dist/agent/tool-history.js +8 -1
  64. package/dist/agent/tool-history.js.map +1 -1
  65. package/dist/agent/turn-outcome.d.ts +4 -1
  66. package/dist/agent/turn-outcome.js +16 -2
  67. package/dist/agent/turn-outcome.js.map +1 -1
  68. package/dist/app/adapters/agent-event-adapter.js +27 -0
  69. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  70. package/dist/app/adapters/current-agent-adapter.js +1 -0
  71. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  72. package/dist/app/adapters/current-interactive-sessions-adapter.d.ts +4 -0
  73. package/dist/app/adapters/current-interactive-sessions-adapter.js +11 -0
  74. package/dist/app/adapters/current-interactive-sessions-adapter.js.map +1 -0
  75. package/dist/app/adapters/current-jobs-adapter.js +4 -3
  76. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  77. package/dist/app/adapters/current-store-adapter.js +4 -3
  78. package/dist/app/adapters/current-store-adapter.js.map +1 -1
  79. package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
  80. package/dist/app/adapters/current-updates-adapter.js +34 -5
  81. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  82. package/dist/app/adapters/quiet-meta-tools.js +2 -1
  83. package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
  84. package/dist/app/controllers/cancel-all-result.d.ts +8 -0
  85. package/dist/app/controllers/cancel-all-result.js +32 -0
  86. package/dist/app/controllers/cancel-all-result.js.map +1 -0
  87. package/dist/app/controllers/job-controller.d.ts +4 -3
  88. package/dist/app/controllers/job-controller.js +9 -6
  89. package/dist/app/controllers/job-controller.js.map +1 -1
  90. package/dist/app/controllers/plan-controller.js +14 -0
  91. package/dist/app/controllers/plan-controller.js.map +1 -1
  92. package/dist/app/controllers/responder-settlement.js +2 -2
  93. package/dist/app/controllers/responder-settlement.js.map +1 -1
  94. package/dist/app/controllers/session-compact-helper.d.ts +23 -1
  95. package/dist/app/controllers/session-compact-helper.js +117 -7
  96. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  97. package/dist/app/controllers/session-context-usage.d.ts +24 -0
  98. package/dist/app/controllers/session-context-usage.js +89 -0
  99. package/dist/app/controllers/session-context-usage.js.map +1 -0
  100. package/dist/app/controllers/session-controller.d.ts +25 -17
  101. package/dist/app/controllers/session-controller.js +142 -128
  102. package/dist/app/controllers/session-controller.js.map +1 -1
  103. package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
  104. package/dist/app/controllers/session-prompt-queue.js +20 -1
  105. package/dist/app/controllers/session-prompt-queue.js.map +1 -1
  106. package/dist/app/controllers/session-responder.js +16 -6
  107. package/dist/app/controllers/session-responder.js.map +1 -1
  108. package/dist/app/controllers/session-turn-request.d.ts +18 -0
  109. package/dist/app/controllers/session-turn-request.js +31 -0
  110. package/dist/app/controllers/session-turn-request.js.map +1 -0
  111. package/dist/app/controllers/turn-continuation.d.ts +14 -0
  112. package/dist/app/controllers/turn-continuation.js +46 -0
  113. package/dist/app/controllers/turn-continuation.js.map +1 -0
  114. package/dist/app/events/app-event.d.ts +21 -0
  115. package/dist/app/events/app-event.js +1 -0
  116. package/dist/app/events/app-event.js.map +1 -1
  117. package/dist/app/ports/agent-port.d.ts +7 -0
  118. package/dist/app/ports/interactive-sessions-port.d.ts +21 -0
  119. package/dist/app/ports/interactive-sessions-port.js +9 -0
  120. package/dist/app/ports/interactive-sessions-port.js.map +1 -0
  121. package/dist/app/ports/jobs-port.d.ts +4 -3
  122. package/dist/app/ports/persistence-port.d.ts +11 -0
  123. package/dist/app/ports/updates-port.d.ts +8 -0
  124. package/dist/attachments/service.js +13 -2
  125. package/dist/attachments/service.js.map +1 -1
  126. package/dist/commands/providers.js +13 -10
  127. package/dist/commands/providers.js.map +1 -1
  128. package/dist/commands/update.d.ts +4 -0
  129. package/dist/commands/update.js +14 -0
  130. package/dist/commands/update.js.map +1 -1
  131. package/dist/index.js +21 -8
  132. package/dist/index.js.map +1 -1
  133. package/dist/interactive-session/artifact-writer.d.ts +54 -0
  134. package/dist/interactive-session/artifact-writer.js +161 -0
  135. package/dist/interactive-session/artifact-writer.js.map +1 -0
  136. package/dist/interactive-session/cleanup.d.ts +36 -0
  137. package/dist/interactive-session/cleanup.js +157 -0
  138. package/dist/interactive-session/cleanup.js.map +1 -0
  139. package/dist/interactive-session/config.d.ts +118 -0
  140. package/dist/interactive-session/config.js +141 -0
  141. package/dist/interactive-session/config.js.map +1 -0
  142. package/dist/interactive-session/input-policy.d.ts +41 -0
  143. package/dist/interactive-session/input-policy.js +163 -0
  144. package/dist/interactive-session/input-policy.js.map +1 -0
  145. package/dist/interactive-session/manager.d.ts +107 -0
  146. package/dist/interactive-session/manager.js +927 -0
  147. package/dist/interactive-session/manager.js.map +1 -0
  148. package/dist/interactive-session/output-store.d.ts +81 -0
  149. package/dist/interactive-session/output-store.js +239 -0
  150. package/dist/interactive-session/output-store.js.map +1 -0
  151. package/dist/interactive-session/output-view.d.ts +20 -0
  152. package/dist/interactive-session/output-view.js +142 -0
  153. package/dist/interactive-session/output-view.js.map +1 -0
  154. package/dist/interactive-session/recovery-journal.d.ts +55 -0
  155. package/dist/interactive-session/recovery-journal.js +115 -0
  156. package/dist/interactive-session/recovery-journal.js.map +1 -0
  157. package/dist/interactive-session/registry.d.ts +59 -0
  158. package/dist/interactive-session/registry.js +170 -0
  159. package/dist/interactive-session/registry.js.map +1 -0
  160. package/dist/interactive-session/runtime.d.ts +107 -0
  161. package/dist/interactive-session/runtime.js +191 -0
  162. package/dist/interactive-session/runtime.js.map +1 -0
  163. package/dist/interactive-session/session-runtime.d.ts +50 -0
  164. package/dist/interactive-session/session-runtime.js +63 -0
  165. package/dist/interactive-session/session-runtime.js.map +1 -0
  166. package/dist/interactive-session/streaming-redactor.d.ts +53 -0
  167. package/dist/interactive-session/streaming-redactor.js +263 -0
  168. package/dist/interactive-session/streaming-redactor.js.map +1 -0
  169. package/dist/interactive-session/telemetry.d.ts +30 -0
  170. package/dist/interactive-session/telemetry.js +47 -0
  171. package/dist/interactive-session/telemetry.js.map +1 -0
  172. package/dist/interactive-session/transport-factory.d.ts +8 -0
  173. package/dist/interactive-session/transport-factory.js +16 -0
  174. package/dist/interactive-session/transport-factory.js.map +1 -0
  175. package/dist/interactive-session/transport-node-pty.d.ts +49 -0
  176. package/dist/interactive-session/transport-node-pty.js +219 -0
  177. package/dist/interactive-session/transport-node-pty.js.map +1 -0
  178. package/dist/interactive-session/transport-pipe.d.ts +9 -0
  179. package/dist/interactive-session/transport-pipe.js +215 -0
  180. package/dist/interactive-session/transport-pipe.js.map +1 -0
  181. package/dist/interactive-session/transport.d.ts +104 -0
  182. package/dist/interactive-session/transport.js +89 -0
  183. package/dist/interactive-session/transport.js.map +1 -0
  184. package/dist/interactive-session/types.d.ts +254 -0
  185. package/dist/interactive-session/types.js +191 -0
  186. package/dist/interactive-session/types.js.map +1 -0
  187. package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
  188. package/dist/llm/adapters/anthropic-tools.js +36 -5
  189. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  190. package/dist/llm/adapters/gemini-tools.d.ts +3 -1
  191. package/dist/llm/adapters/gemini-tools.js +5 -2
  192. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  193. package/dist/llm/adapters/ollama-tools.js +8 -3
  194. package/dist/llm/adapters/ollama-tools.js.map +1 -1
  195. package/dist/llm/adapters/openai-tools.js +8 -1
  196. package/dist/llm/adapters/openai-tools.js.map +1 -1
  197. package/dist/llm/agentrouter.js +2 -0
  198. package/dist/llm/agentrouter.js.map +1 -1
  199. package/dist/llm/anthropic.d.ts +8 -0
  200. package/dist/llm/anthropic.js +68 -9
  201. package/dist/llm/anthropic.js.map +1 -1
  202. package/dist/llm/aws-mantle.js +60 -12
  203. package/dist/llm/aws-mantle.js.map +1 -1
  204. package/dist/llm/bynara.js +2 -0
  205. package/dist/llm/bynara.js.map +1 -1
  206. package/dist/llm/capabilities.d.ts +7 -0
  207. package/dist/llm/capabilities.js +22 -0
  208. package/dist/llm/capabilities.js.map +1 -1
  209. package/dist/llm/gemini.d.ts +3 -0
  210. package/dist/llm/gemini.js +71 -11
  211. package/dist/llm/gemini.js.map +1 -1
  212. package/dist/llm/groq.d.ts +20 -1
  213. package/dist/llm/groq.js +32 -4
  214. package/dist/llm/groq.js.map +1 -1
  215. package/dist/llm/http.d.ts +29 -0
  216. package/dist/llm/http.js +122 -29
  217. package/dist/llm/http.js.map +1 -1
  218. package/dist/llm/kimchi.js +2 -0
  219. package/dist/llm/kimchi.js.map +1 -1
  220. package/dist/llm/nvidia.js +2 -0
  221. package/dist/llm/nvidia.js.map +1 -1
  222. package/dist/llm/ollama.d.ts +5 -0
  223. package/dist/llm/ollama.js +53 -6
  224. package/dist/llm/ollama.js.map +1 -1
  225. package/dist/llm/openai.js +2 -0
  226. package/dist/llm/openai.js.map +1 -1
  227. package/dist/llm/openrouter.js +2 -0
  228. package/dist/llm/openrouter.js.map +1 -1
  229. package/dist/llm/qwen-cloud.js +2 -0
  230. package/dist/llm/qwen-cloud.js.map +1 -1
  231. package/dist/llm/router.d.ts +16 -0
  232. package/dist/llm/router.js +79 -23
  233. package/dist/llm/router.js.map +1 -1
  234. package/dist/llm/sampling.d.ts +32 -0
  235. package/dist/llm/sampling.js +48 -0
  236. package/dist/llm/sampling.js.map +1 -0
  237. package/dist/llm/stream-progress.d.ts +4 -0
  238. package/dist/llm/stream-progress.js +43 -0
  239. package/dist/llm/stream-progress.js.map +1 -0
  240. package/dist/llm/system-messages.d.ts +29 -0
  241. package/dist/llm/system-messages.js +51 -0
  242. package/dist/llm/system-messages.js.map +1 -0
  243. package/dist/llm/token-usage.d.ts +4 -1
  244. package/dist/llm/token-usage.js +56 -4
  245. package/dist/llm/token-usage.js.map +1 -1
  246. package/dist/llm/tool-protocol.js +30 -0
  247. package/dist/llm/tool-protocol.js.map +1 -1
  248. package/dist/os/process-identity.d.ts +46 -0
  249. package/dist/os/process-identity.js +147 -0
  250. package/dist/os/process-identity.js.map +1 -0
  251. package/dist/os/process-tree.d.ts +15 -0
  252. package/dist/os/process-tree.js +77 -0
  253. package/dist/os/process-tree.js.map +1 -0
  254. package/dist/prompts/embedded.js +1 -1
  255. package/dist/prompts/embedded.js.map +1 -1
  256. package/dist/prompts/system.agent.md +13 -12
  257. package/dist/repl/prompt-line.d.ts +5 -0
  258. package/dist/repl/prompt-line.js +7 -1
  259. package/dist/repl/prompt-line.js.map +1 -1
  260. package/dist/repl.js +71 -41
  261. package/dist/repl.js.map +1 -1
  262. package/dist/safety/classifier.js +98 -44
  263. package/dist/safety/classifier.js.map +1 -1
  264. package/dist/safety/engagement-policy.d.ts +8 -0
  265. package/dist/safety/engagement-policy.js +52 -7
  266. package/dist/safety/engagement-policy.js.map +1 -1
  267. package/dist/safety/patterns.d.ts +44 -0
  268. package/dist/safety/patterns.js +168 -37
  269. package/dist/safety/patterns.js.map +1 -1
  270. package/dist/store/config.d.ts +9 -1
  271. package/dist/store/config.js +51 -1
  272. package/dist/store/config.js.map +1 -1
  273. package/dist/store/history-index.d.ts +12 -0
  274. package/dist/store/history-index.js +47 -18
  275. package/dist/store/history-index.js.map +1 -1
  276. package/dist/store/history.d.ts +5 -2
  277. package/dist/store/history.js +153 -62
  278. package/dist/store/history.js.map +1 -1
  279. package/dist/store/plan.d.ts +81 -5
  280. package/dist/store/plan.js +406 -46
  281. package/dist/store/plan.js.map +1 -1
  282. package/dist/store/redaction-cache.d.ts +12 -0
  283. package/dist/store/redaction-cache.js +51 -0
  284. package/dist/store/redaction-cache.js.map +1 -0
  285. package/dist/store/responder-settlement.d.ts +5 -1
  286. package/dist/store/responder-settlement.js +42 -30
  287. package/dist/store/responder-settlement.js.map +1 -1
  288. package/dist/store/scope.js +36 -27
  289. package/dist/store/scope.js.map +1 -1
  290. package/dist/store/task-transitions.d.ts +19 -0
  291. package/dist/store/task-transitions.js +46 -0
  292. package/dist/store/task-transitions.js.map +1 -0
  293. package/dist/tools/capabilities.js +33 -18
  294. package/dist/tools/capabilities.js.map +1 -1
  295. package/dist/tools/command-intent.d.ts +24 -0
  296. package/dist/tools/command-intent.js +158 -4
  297. package/dist/tools/command-intent.js.map +1 -1
  298. package/dist/tools/definitions.d.ts +5 -1
  299. package/dist/tools/definitions.js +257 -21
  300. package/dist/tools/definitions.js.map +1 -1
  301. package/dist/tools/fs.d.ts +16 -0
  302. package/dist/tools/fs.js +122 -46
  303. package/dist/tools/fs.js.map +1 -1
  304. package/dist/tools/image.js +1 -1
  305. package/dist/tools/image.js.map +1 -1
  306. package/dist/tools/interactive-session-tools.d.ts +11 -0
  307. package/dist/tools/interactive-session-tools.js +261 -0
  308. package/dist/tools/interactive-session-tools.js.map +1 -0
  309. package/dist/tools/jobs.d.ts +78 -4
  310. package/dist/tools/jobs.js +486 -127
  311. package/dist/tools/jobs.js.map +1 -1
  312. package/dist/tools/net-ping-sweep.d.ts +3 -1
  313. package/dist/tools/net-ping-sweep.js +64 -26
  314. package/dist/tools/net-ping-sweep.js.map +1 -1
  315. package/dist/tools/nmap-runner.js +5 -2
  316. package/dist/tools/nmap-runner.js.map +1 -1
  317. package/dist/tools/pdf.js +50 -7
  318. package/dist/tools/pdf.js.map +1 -1
  319. package/dist/tools/registry.js +158 -96
  320. package/dist/tools/registry.js.map +1 -1
  321. package/dist/tools/shell.d.ts +24 -2
  322. package/dist/tools/shell.js +113 -28
  323. package/dist/tools/shell.js.map +1 -1
  324. package/dist/tools/tool-types.d.ts +2 -0
  325. package/dist/tools/validate.d.ts +8 -5
  326. package/dist/tools/validate.js +177 -7
  327. package/dist/tools/validate.js.map +1 -1
  328. package/dist/tools/wordlists.js +69 -17
  329. package/dist/tools/wordlists.js.map +1 -1
  330. package/dist/tui/state.d.ts +4 -0
  331. package/dist/tui/state.js +88 -2
  332. package/dist/tui/state.js.map +1 -1
  333. package/dist/tui-v2/actions/keymap.js +3 -8
  334. package/dist/tui-v2/actions/keymap.js.map +1 -1
  335. package/dist/tui-v2/app/App.js +52 -4
  336. package/dist/tui-v2/app/App.js.map +1 -1
  337. package/dist/tui-v2/app/commands/config-commands.js +5 -2
  338. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  339. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  340. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  341. package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
  342. package/dist/tui-v2/app/plan-lifecycle.js +2 -1
  343. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  344. package/dist/tui-v2/bootstrap/composition-root.d.ts +3 -0
  345. package/dist/tui-v2/bootstrap/composition-root.js +3 -0
  346. package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
  347. package/dist/tui-v2/bootstrap/start-tui-v2.js +10 -0
  348. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  349. package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
  350. package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
  351. package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
  352. package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
  353. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  354. package/dist/tui-v2/components/plan/plan-view.js +9 -6
  355. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  356. package/dist/tui-v2/components/status/status-line.d.ts +15 -0
  357. package/dist/tui-v2/components/status/status-line.js +33 -25
  358. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  359. package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
  360. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  361. package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
  362. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  363. package/dist/tui-v2/components/transcript/intro-card.js +3 -0
  364. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  365. package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
  366. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  367. package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
  368. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  369. package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
  370. package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
  371. package/dist/tui-v2/composer/composer-editor.js +12 -6
  372. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  373. package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
  374. package/dist/tui-v2/rendering/incremental-strip.js +89 -0
  375. package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
  376. package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
  377. package/dist/tui-v2/rendering/plan-view.js +71 -2
  378. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  379. package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
  380. package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
  381. package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
  382. package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
  383. package/dist/tui-v2/rendering/thinking-tail.js +26 -0
  384. package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
  385. package/dist/tui-v2/state/transcript-hydrate.js +6 -2
  386. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  387. package/dist/tui-v2/state/transcript-reducer.js +146 -25
  388. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  389. package/dist/tui-v2/state/transcript-types.d.ts +6 -0
  390. package/dist/tui-v2/state/transcript-types.js +17 -0
  391. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  392. package/dist/types.d.ts +34 -0
  393. package/dist/types.js.map +1 -1
  394. package/dist/ui/intro-header.d.ts +1 -0
  395. package/dist/ui/intro-header.js +10 -13
  396. package/dist/ui/intro-header.js.map +1 -1
  397. package/dist/ui/markdown.js +9 -2
  398. package/dist/ui/markdown.js.map +1 -1
  399. package/dist/ui/mentions.d.ts +5 -0
  400. package/dist/ui/mentions.js +66 -7
  401. package/dist/ui/mentions.js.map +1 -1
  402. package/dist/ui/thinking.d.ts +5 -1
  403. package/dist/ui/thinking.js +3 -2
  404. package/dist/ui/thinking.js.map +1 -1
  405. package/dist/version.generated.d.ts +2 -2
  406. package/dist/version.generated.js +2 -2
  407. package/package.json +2 -1
@@ -1,13 +1,15 @@
1
- import { spawn, execFileSync } from "node:child_process";
2
- import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { spawn } from "node:child_process";
2
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { mkdir, open, stat } from "node:fs/promises";
4
- import { basename, join } from "node:path";
4
+ import { basename, join, sep } from "node:path";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { StringDecoder } from "node:string_decoder";
7
7
  import { redactSecrets } from "../llm/provider.js";
8
8
  import { safeCwd } from "../os/cwd.js";
9
9
  import { getJobsDir } from "../store/paths.js";
10
10
  import { resolveShell } from "./shell.js";
11
+ import { terminateProcessTree } from "../os/process-tree.js";
12
+ import { forgetProcessIdentity, processIdentity } from "../os/process-identity.js";
11
13
  export function formatJobElapsed(job, now = Date.now()) {
12
14
  const startedAt = Date.parse(job.startedAt);
13
15
  const endedAt = job.endedAt ? Date.parse(job.endedAt) : now;
@@ -31,6 +33,15 @@ function commandDisplay(command) {
31
33
  return command;
32
34
  return command.display ?? [command.command, ...command.argv].map(displayArg).join(" ");
33
35
  }
36
+ function launchFollowUp(id, responder) {
37
+ return responder
38
+ ? "Responder owns completion tracking for this self-completing job. " +
39
+ "Do not poll it with shell.tail/shell.jobs, sleep, or read its artifact. " +
40
+ "Continue other work; if not already delivered, its terminal result will be delivered automatically."
41
+ : "OS launch does not prove application readiness or continued liveness. " +
42
+ `This is a normal background job: use shell.tail {"id":"${id}"} with nextOffset and shell.jobs for status; ` +
43
+ "for a server or watcher, also run an application readiness probe. Do not launch a duplicate.";
44
+ }
34
45
  const PER_FILE_BYTES = 1024 * 1024;
35
46
  const MAX_STREAM_BYTES = 16 * 1024 * 1024;
36
47
  const DEFAULT_TAIL_BYTES = 8_000;
@@ -42,10 +53,16 @@ const MAX_DURABLE_TERMINAL_JOBS = 80;
42
53
  const TERMINAL_JOB_MAX_AGE_MS = 48 * 60 * 60 * 1000;
43
54
  const ARCHIVED_UNSETTLED_MAX_AGE_MS = 24 * 60 * 60 * 1000;
44
55
  const MAX_ARCHIVED_UNSETTLED_NOTIFICATIONS = 40;
56
+ const MAX_SUPERSEDED_REVISIONS = 5;
57
+ const SETTLEMENT_MAX_BACKOFF_MS = 30_000;
58
+ const LIVENESS_WATCH_INTERVAL_MS = 2_000;
59
+ const SETTLEMENT_DEAD_LETTER_MS = 10 * 60_000;
45
60
  /** Max lines shell.jobs returns to the model (running first, then recent). */
46
61
  const LIST_JOBS_MAX_LINES = 40;
47
62
  /** Coalesce window for chatty stdout/stderr progress persistence + UI events. */
48
63
  const PROGRESS_FLUSH_MS = 250;
64
+ /** Durable registry cadence for running-job progress (UI updates stay at 250ms). */
65
+ const DURABLE_PROGRESS_FLUSH_MS = 5_000;
49
66
  /**
50
67
  * A live job with no in-process ChildProcess handle (resumed session, or a
51
68
  * handle that was released) is only declared "lost" after failing the liveness
@@ -55,6 +72,8 @@ const PROGRESS_FLUSH_MS = 250;
55
72
  * elapsed, and a ✗ status=lost that later flipped back to exit=0).
56
73
  */
57
74
  const LIVENESS_LOST_GRACE_MS = 8_000;
75
+ /** Minimum spacing between liveness probes for the same job. */
76
+ const LIVENESS_PROBE_INTERVAL_MS = 1_000;
58
77
  /**
59
78
  * Number of trailing bytes in `buf` that form an incomplete multi-byte UTF-8
60
79
  * sequence (a lead byte whose continuation bytes were cut off by the read
@@ -91,27 +110,6 @@ function processAlive(pid) {
91
110
  return error.code === "EPERM";
92
111
  }
93
112
  }
94
- function processIdentity(pid) {
95
- if (!pid || process.platform === "win32")
96
- return undefined;
97
- try {
98
- // Start time only: it is invariant for a pid's lifetime and changes on
99
- // reuse, so it is a sufficient pid-reuse guard. The command line is
100
- // deliberately excluded — `sh -c "cmd"` execs into `cmd`, mutating
101
- // `ps command=` mid-run, which made a live job fail its OWN identity check
102
- // (stop refused with "process identity no longer matches", liveness falsely
103
- // marked lost, and double-Esc cancel unable to kill the process).
104
- const value = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
105
- encoding: "utf8",
106
- timeout: 2_000,
107
- stdio: ["ignore", "pipe", "ignore"],
108
- }).trim();
109
- return value ? createHash("sha256").update(value).digest("hex") : undefined;
110
- }
111
- catch {
112
- return undefined;
113
- }
114
- }
115
113
  /**
116
114
  * Heuristic for legacy registry rows that were actually agent tool-stall
117
115
  * trackers (commandDisplay = "fs.list /path", "shell.jobs {}", …), not real
@@ -268,7 +266,9 @@ export class JobManager {
268
266
  authorizationTimers = new Map();
269
267
  responderLeases = new Map();
270
268
  settlementTimers = new Map();
271
- settlementAttempts = new Map();
269
+ pendingSettlements = new Map();
270
+ consumedResponderResults = new Map();
271
+ livenessWatchTimer;
272
272
  finalizations = new Map();
273
273
  listeners = new Set();
274
274
  /**
@@ -278,6 +278,7 @@ export class JobManager {
278
278
  * observed alive again or finalized.
279
279
  */
280
280
  livenessMisses = new Map();
281
+ livenessCheckedAt = new Map();
281
282
  registryRetryTimer;
282
283
  registryPath;
283
284
  transientV2RegistryPath;
@@ -286,10 +287,84 @@ export class JobManager {
286
287
  this.registryPath = join(this.jobsDir, REGISTRY_FILE);
287
288
  this.transientV2RegistryPath = join(this.jobsDir, TRANSIENT_V2_REGISTRY_FILE);
288
289
  this.loadAndReconcile();
290
+ this.sweepOrphanArtifacts();
289
291
  }
290
292
  isDurable(job) {
291
293
  return job.kind !== "ephemeral";
292
294
  }
295
+ artifactPathsOf(job) {
296
+ return [
297
+ job.artifactPath,
298
+ job.stdoutArtifact,
299
+ job.stderrArtifact,
300
+ ...(job.artifacts?.stdout.chunks ?? []),
301
+ ...(job.artifacts?.stderr.chunks ?? []),
302
+ ].filter((path) => Boolean(path));
303
+ }
304
+ /** Delete a dropped job's artifact chunks, never one still referenced. */
305
+ removeJobArtifacts(job) {
306
+ const paths = this.artifactPathsOf(job).filter((path) => path.startsWith(`${this.jobsDir}${sep}`));
307
+ if (paths.length === 0)
308
+ return;
309
+ const referenced = new Set();
310
+ for (const other of this.jobs.values()) {
311
+ for (const path of this.artifactPathsOf(other))
312
+ referenced.add(path);
313
+ }
314
+ for (const path of new Set(paths)) {
315
+ if (referenced.has(path))
316
+ continue;
317
+ try {
318
+ rmSync(path, { force: true });
319
+ }
320
+ catch {
321
+ // Artifact cleanup is best-effort; a locked file is retried on boot.
322
+ }
323
+ }
324
+ }
325
+ /** Drop a job row plus its per-job caches and artifacts. */
326
+ forgetJob(id) {
327
+ const job = this.jobs.get(id);
328
+ this.jobs.delete(id);
329
+ for (const [notificationId, notification] of this.notifications) {
330
+ if (notification.jobId === id)
331
+ this.notifications.delete(notificationId);
332
+ }
333
+ this.consumedResponderResults.delete(id);
334
+ this.livenessMisses.delete(id);
335
+ this.livenessCheckedAt.delete(id);
336
+ if (!job)
337
+ return;
338
+ forgetProcessIdentity(job.pid);
339
+ this.removeJobArtifacts(job);
340
+ }
341
+ /** Remove artifact files whose job row no longer exists. */
342
+ sweepOrphanArtifacts() {
343
+ let names;
344
+ try {
345
+ names = readdirSync(this.jobsDir);
346
+ }
347
+ catch {
348
+ return;
349
+ }
350
+ const known = new Set(this.jobs.keys());
351
+ const now = Date.now();
352
+ for (const name of names) {
353
+ if (!/\.(stdout|stderr)\.log(\.\d+)?$/.test(name))
354
+ continue;
355
+ if ([...known].some((id) => name.includes(`-${id}.`)))
356
+ continue;
357
+ const path = join(this.jobsDir, name);
358
+ try {
359
+ if (now - statSync(path).mtimeMs <= TERMINAL_JOB_MAX_AGE_MS)
360
+ continue;
361
+ rmSync(path, { force: true });
362
+ }
363
+ catch {
364
+ // Ignore unreadable/locked leftovers.
365
+ }
366
+ }
367
+ }
293
368
  isLive(job) {
294
369
  return (job.status === "running" ||
295
370
  job.status === "starting" ||
@@ -355,18 +430,17 @@ export class JobManager {
355
430
  if (!current || (leaseId && current !== leaseId))
356
431
  return;
357
432
  this.responderLeases.delete(sessionId);
358
- const archivedAt = new Date().toISOString();
359
433
  let changed = false;
360
434
  for (const notification of this.notifications.values()) {
361
- if (notification.ownerSessionId === sessionId &&
362
- notification.responderLeaseId === current) {
363
- this.claimedNotifications.delete(notification.id);
435
+ if (notification.ownerSessionId !== sessionId ||
436
+ notification.responderLeaseId !== current) {
437
+ continue;
364
438
  }
365
- if (notification.ownerSessionId === sessionId &&
366
- notification.responderLeaseId === current &&
367
- !notification.acknowledgedAt &&
368
- !notification.archivedAt) {
369
- notification.archivedAt = archivedAt;
439
+ this.claimedNotifications.delete(notification.id);
440
+ // Releasing a runtime lease must not discard an unread durable result;
441
+ // detach it so the next lease for this session adopts it.
442
+ if (!notification.acknowledgedAt && !notification.archivedAt) {
443
+ delete notification.responderLeaseId;
370
444
  changed = true;
371
445
  }
372
446
  }
@@ -399,10 +473,60 @@ export class JobManager {
399
473
  JSON.stringify(stdoutArtifact) ||
400
474
  JSON.stringify(existing.stderrArtifact) !==
401
475
  JSON.stringify(stderrArtifact)));
402
- const reopenSettlement = Boolean(existing?.acknowledgedAt && completionChanged);
403
- const responderLeaseId = job.responderLeaseId ?? existing?.responderLeaseId;
404
- const leaseIsActive = Boolean(responderLeaseId &&
405
- this.responderLeases.get(job.ownerSessionId) === responderLeaseId);
476
+ const resultHash = createHash("sha256")
477
+ .update([
478
+ job.status,
479
+ endedAt,
480
+ String(job.exitCode ?? ""),
481
+ String(job.signal ?? ""),
482
+ JSON.stringify(stdoutArtifact),
483
+ JSON.stringify(stderrArtifact),
484
+ ].join("\0"))
485
+ .digest("hex")
486
+ .slice(0, 24);
487
+ const consumed = this.consumedResponderResults.get(job.id);
488
+ if (!existing && consumed?.resultHash === resultHash) {
489
+ return { created: false, updated: false };
490
+ }
491
+ const correctsConsumed = Boolean(!existing && consumed && consumed.resultHash !== resultHash);
492
+ if (correctsConsumed)
493
+ this.consumedResponderResults.delete(job.id);
494
+ // A materially different authoritative result is a new revision: every
495
+ // delivery/read/analysis marker belonged to the superseded result and must
496
+ // not suppress review of the corrected one.
497
+ const supersedes = Boolean(existing &&
498
+ completionChanged &&
499
+ (existing.resultHash === undefined || existing.resultHash !== resultHash));
500
+ const resultRevision = supersedes
501
+ ? (existing?.resultRevision ?? 1) + 1
502
+ : correctsConsumed
503
+ ? (consumed?.resultRevision ?? 1) + 1
504
+ : (existing?.resultRevision ?? 1);
505
+ const supersededRevisions = supersedes && existing
506
+ ? [
507
+ ...(existing.supersededRevisions ?? []),
508
+ {
509
+ resultRevision: existing.resultRevision ?? 1,
510
+ resultHash: existing.resultHash ?? "",
511
+ status: existing.status,
512
+ endedAt: existing.endedAt,
513
+ ...(existing.exitCode !== undefined
514
+ ? { exitCode: existing.exitCode }
515
+ : {}),
516
+ ...(existing.signal !== undefined ? { signal: existing.signal } : {}),
517
+ ...(existing.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
518
+ ...(existing.readAt ? { readAt: existing.readAt } : {}),
519
+ ...(existing.analyzedAt ? { analyzedAt: existing.analyzedAt } : {}),
520
+ ...(existing.acknowledgedAt
521
+ ? { acknowledgedAt: existing.acknowledgedAt }
522
+ : {}),
523
+ ...(existing.settledAt ? { settledAt: existing.settledAt } : {}),
524
+ },
525
+ ].slice(-MAX_SUPERSEDED_REVISIONS)
526
+ : existing?.supersededRevisions;
527
+ const carried = supersedes ? undefined : existing;
528
+ const activeLeaseId = this.responderLeases.get(job.ownerSessionId);
529
+ const responderLeaseId = activeLeaseId ?? job.responderLeaseId ?? existing?.responderLeaseId;
406
530
  const notification = {
407
531
  id: existing?.id ??
408
532
  `completion:${createHash("sha256").update(`${job.ownerSessionId}\0${job.id}\0${job.startedAt}`).digest("hex").slice(0, 24)}`,
@@ -423,21 +547,34 @@ export class JobManager {
423
547
  ...(job.signal !== undefined ? { signal: job.signal } : {}),
424
548
  ...(job.monitor !== undefined ? { monitor: job.monitor } : {}),
425
549
  ...(responderLeaseId ? { responderLeaseId } : {}),
426
- ...(existing?.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
427
- ...(existing?.readAt ? { readAt: existing.readAt } : {}),
428
- ...((existing?.analyzedAt ?? existing?.acknowledgedAt)
429
- ? { analyzedAt: existing?.analyzedAt ?? existing?.acknowledgedAt }
550
+ resultRevision,
551
+ resultHash,
552
+ ...(supersededRevisions?.length ? { supersededRevisions } : {}),
553
+ ...(carried?.deliveryStartedAt
554
+ ? { deliveryStartedAt: carried.deliveryStartedAt }
555
+ : {}),
556
+ ...(carried?.deliveredAt ? { deliveredAt: carried.deliveredAt } : {}),
557
+ ...(carried?.readAt ? { readAt: carried.readAt } : {}),
558
+ ...((carried?.analyzedAt ?? carried?.acknowledgedAt)
559
+ ? { analyzedAt: carried?.analyzedAt ?? carried?.acknowledgedAt }
560
+ : {}),
561
+ ...(carried?.acknowledgedAt
562
+ ? { acknowledgedAt: carried.acknowledgedAt }
430
563
  : {}),
431
- ...(!reopenSettlement && existing?.acknowledgedAt
432
- ? { acknowledgedAt: existing.acknowledgedAt }
564
+ ...(carried?.settledAt ? { settledAt: carried.settledAt } : {}),
565
+ ...(carried?.discardedAt
566
+ ? {
567
+ discardedAt: carried.discardedAt,
568
+ ...(carried.discardReason
569
+ ? { discardReason: carried.discardReason }
570
+ : {}),
571
+ wakeOnCompletion: false,
572
+ }
433
573
  : {}),
434
- ...(existing?.settledAt ? { settledAt: existing.settledAt } : {}),
435
- ...(!leaseIsActive
436
- ? { archivedAt: existing?.archivedAt ?? endedAt }
437
- : existing?.archivedAt
438
- ? { archivedAt: existing.archivedAt }
439
- : {}),
574
+ ...(carried?.archivedAt ? { archivedAt: carried.archivedAt } : {}),
440
575
  };
576
+ if (supersedes)
577
+ this.claimedNotifications.delete(notification.id);
441
578
  const updated = Boolean(existing && JSON.stringify(existing) !== JSON.stringify(notification));
442
579
  this.notifications.set(notification.id, notification);
443
580
  return { notification, created: !existing, updated };
@@ -514,11 +651,22 @@ export class JobManager {
514
651
  }
515
652
  }
516
653
  /** Reconcile a restored process that no longer has a ChildProcess close event. */
517
- refreshJobLiveness(job) {
654
+ refreshJobLiveness(job, options = {}) {
518
655
  if (!this.isLive(job) || this.processes.has(job.id)) {
519
656
  this.livenessMisses.delete(job.id);
657
+ this.livenessCheckedAt.delete(job.id);
658
+ return;
659
+ }
660
+ // Subscribers can ask for state many times per frame; one probe per job per
661
+ // interval is enough because the lost grace window is far longer.
662
+ const checkedAt = this.livenessCheckedAt.get(job.id);
663
+ const probeAt = Date.now();
664
+ if (!options.force &&
665
+ checkedAt !== undefined &&
666
+ probeAt - checkedAt < LIVENESS_PROBE_INTERVAL_MS) {
520
667
  return;
521
668
  }
669
+ this.livenessCheckedAt.set(job.id, probeAt);
522
670
  // Primary signal is the pid itself. If the process is alive we keep the job
523
671
  // running and only ever declare "lost" on a PROVEN pid reuse (both the
524
672
  // stored and the current identity are present AND differ). An identity read
@@ -532,6 +680,9 @@ export class JobManager {
532
680
  return;
533
681
  }
534
682
  }
683
+ else {
684
+ forgetProcessIdentity(job.pid);
685
+ }
535
686
  // The process looks gone (or is a proven pid reuse). Require the miss to
536
687
  // persist across a grace window before finalizing — a single transient
537
688
  // hiccup must not kill a job whose real close event is still in flight.
@@ -570,47 +721,122 @@ export class JobManager {
570
721
  clearTimeout(existing);
571
722
  const timer = setTimeout(() => {
572
723
  this.settlementTimers.delete(job.id);
724
+ const revision = this.notificationForJob(job.id)?.resultRevision ?? 1;
573
725
  void import("../store/responder-settlement.js")
574
- .then(({ settleResponderJob }) => settleResponderJob({ ...job }))
726
+ .then(({ settleResponderJob }) => settleResponderJob({ ...job }, { resultRevision: revision }))
575
727
  .then((result) => {
576
728
  if (result === "applied" || result === "noop") {
577
- this.settlementAttempts.delete(job.id);
578
- const notification = this.notificationForJob(job.id);
579
- if (notification && !notification.settledAt) {
580
- notification.settledAt = new Date().toISOString();
581
- if (!this.persistSync())
582
- this.scheduleRegistryRetry();
583
- this.emit({
584
- type: "notification",
585
- jobId: job.id,
586
- notificationId: notification.id,
587
- });
588
- }
729
+ this.completeSettlement(job.id, revision);
589
730
  return;
590
731
  }
591
- const attempt = (this.settlementAttempts.get(job.id) ?? 0) + 1;
592
- this.settlementAttempts.set(job.id, attempt);
593
- if (attempt < 8) {
594
- this.scheduleTaskSettlement(job, Math.min(5_000, 50 * 2 ** attempt));
595
- }
596
- else {
597
- this.settlementAttempts.delete(job.id);
598
- }
732
+ this.retrySettlement(job, revision, result);
599
733
  })
600
- .catch(() => {
601
- const attempt = (this.settlementAttempts.get(job.id) ?? 0) + 1;
602
- this.settlementAttempts.set(job.id, attempt);
603
- if (attempt < 8) {
604
- this.scheduleTaskSettlement(job, Math.min(5_000, 50 * 2 ** attempt));
605
- }
606
- else {
607
- this.settlementAttempts.delete(job.id);
608
- }
734
+ .catch((error) => {
735
+ this.retrySettlement(job, revision, error instanceof Error ? error.message : String(error));
609
736
  });
610
737
  }, delayMs);
611
738
  timer.unref?.();
612
739
  this.settlementTimers.set(job.id, timer);
613
740
  }
741
+ completeSettlement(jobId, resultRevision) {
742
+ this.pendingSettlements.delete(jobId);
743
+ const notification = this.notificationForJob(jobId);
744
+ if (!notification ||
745
+ (notification.resultRevision ?? 1) !== resultRevision ||
746
+ notification.settledAt) {
747
+ if (!this.persistSync())
748
+ this.scheduleRegistryRetry();
749
+ return;
750
+ }
751
+ notification.settledAt = new Date().toISOString();
752
+ if (!this.persistSync())
753
+ this.scheduleRegistryRetry();
754
+ this.emit({
755
+ type: "notification",
756
+ jobId,
757
+ notificationId: notification.id,
758
+ });
759
+ }
760
+ /**
761
+ * Retry until the projection lands. The marker is durable, so a crash or a
762
+ * long-lived plan-write conflict cannot silently strand a green child, and an
763
+ * unrecoverable case is dead-lettered with a visible reason.
764
+ */
765
+ retrySettlement(job, resultRevision, reason) {
766
+ const now = new Date().toISOString();
767
+ const previous = this.pendingSettlements.get(job.id);
768
+ const carryOver = previous && previous.resultRevision === resultRevision ? previous : undefined;
769
+ const attempts = (carryOver?.attempts ?? 0) + 1;
770
+ const firstAttemptAt = carryOver?.firstAttemptAt ?? now;
771
+ const elapsed = Date.now() - Date.parse(firstAttemptAt);
772
+ const deadLettered = Number.isFinite(elapsed) && elapsed > SETTLEMENT_DEAD_LETTER_MS;
773
+ this.pendingSettlements.set(job.id, {
774
+ jobId: job.id,
775
+ resultRevision,
776
+ attempts,
777
+ firstAttemptAt,
778
+ lastAttemptAt: now,
779
+ lastReason: reason,
780
+ ...(deadLettered ? { deadLetteredAt: now } : {}),
781
+ });
782
+ if (!this.persistSync())
783
+ this.scheduleRegistryRetry();
784
+ this.emit({ type: "job", jobId: job.id });
785
+ if (deadLettered)
786
+ return;
787
+ this.scheduleTaskSettlement(job, Math.min(SETTLEMENT_MAX_BACKOFF_MS, 50 * 2 ** Math.min(attempts, 10)));
788
+ }
789
+ /**
790
+ * One coalesced, unref'd watcher that reconciles restored live jobs without a
791
+ * UI read. Without it a headless session can leave a finished responder job
792
+ * "running" forever, because liveness was only refreshed by list/get calls.
793
+ */
794
+ scheduleLivenessWatch() {
795
+ if (this.livenessWatchTimer)
796
+ return;
797
+ const restored = [...this.jobs.values()].filter((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
798
+ if (restored.length === 0)
799
+ return;
800
+ const timer = setInterval(() => {
801
+ const pending = [...this.jobs.values()].filter((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
802
+ for (const job of pending)
803
+ this.refreshJobLiveness(job);
804
+ const remaining = [...this.jobs.values()].some((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
805
+ if (remaining)
806
+ return;
807
+ clearInterval(timer);
808
+ this.livenessWatchTimer = undefined;
809
+ }, LIVENESS_WATCH_INTERVAL_MS);
810
+ timer.unref?.();
811
+ this.livenessWatchTimer = timer;
812
+ }
813
+ /** Ownership guard: a receipt may only be mutated by its owning session. */
814
+ ownsNotification(notification, sessionId) {
815
+ if (!notification)
816
+ return false;
817
+ return sessionId === undefined || notification.ownerSessionId === sessionId;
818
+ }
819
+ /** Receipt for a launch that was deduplicated by its delegation key. */
820
+ startJobReceipt(job, reason) {
821
+ return {
822
+ ok: true,
823
+ output: `Reusing background job id=${job.id} (${reason}) pid=${job.pid ?? "?"} status=${job.status}\n` +
824
+ `Command: ${job.commandDisplay}\nArtifact: ${job.stdoutArtifact}\n` +
825
+ `The duplicate launch was not started. ${launchFollowUp(job.id, job.responder === true)}`,
826
+ outputPath: job.stdoutArtifact,
827
+ backgroundJob: {
828
+ id: job.id,
829
+ status: job.status,
830
+ artifactPath: job.stdoutArtifact,
831
+ ...(job.responder ? { responder: true } : {}),
832
+ nextOffset: 0,
833
+ },
834
+ };
835
+ }
836
+ /** Terminal results whose plan child could not be settled yet. */
837
+ getPendingSettlements() {
838
+ return [...this.pendingSettlements.values()];
839
+ }
614
840
  /**
615
841
  * Register an in-flight tool for stall tracking only.
616
842
  * Never appears in shell.jobs and never touches the durable registry.
@@ -644,7 +870,7 @@ export class JobManager {
644
870
  if (!this.isLive(job))
645
871
  this.clearAuthorizationTimer(id);
646
872
  if (!this.isDurable(job) && !this.isLive(job)) {
647
- this.jobs.delete(id);
873
+ this.forgetJob(id);
648
874
  this.emit({ type: "job", jobId: id });
649
875
  return;
650
876
  }
@@ -661,6 +887,14 @@ export class JobManager {
661
887
  return { ok: false, output: `Engagement authorization for ${options.authorization.target} has expired.`, exitCode: 1 };
662
888
  }
663
889
  }
890
+ if (options?.delegationId) {
891
+ // A retried tool call must not spawn a second scanner: the delegation key
892
+ // identifies the work, so the existing job is returned unchanged.
893
+ const existing = [...this.jobs.values()].find((candidate) => candidate.delegationId === options.delegationId &&
894
+ candidate.ownerSessionId === (options.ownerSessionId ?? "unknown"));
895
+ if (existing)
896
+ return this.startJobReceipt(existing, "existing delegation");
897
+ }
664
898
  const id = randomUUID().slice(0, 8);
665
899
  const cwd = options?.cwd ?? safeCwd();
666
900
  try {
@@ -691,7 +925,7 @@ export class JobManager {
691
925
  exitCode: 127,
692
926
  };
693
927
  }
694
- await mkdir(this.jobsDir, { recursive: true });
928
+ await mkdir(this.jobsDir, { recursive: true, mode: 0o700 });
695
929
  const prefix = `${new Date().toISOString().replace(/[:.]/g, "-")}-${id}`;
696
930
  const stdoutArtifact = join(this.jobsDir, `${prefix}.stdout.log`);
697
931
  const stderrArtifact = join(this.jobsDir, `${prefix}.stderr.log`);
@@ -721,6 +955,7 @@ export class JobManager {
721
955
  ...(options?.name ? { name: options.name } : {}),
722
956
  ...(options?.taskId ? { taskId: options.taskId } : {}),
723
957
  ...(options?.parentTaskId ? { parentTaskId: options.parentTaskId } : {}),
958
+ ...(options?.delegationId ? { delegationId: options.delegationId } : {}),
724
959
  ...(options?.wakeOnCompletion !== undefined ? { wakeOnCompletion: options.wakeOnCompletion } : {}),
725
960
  ...(options?.responder !== undefined ? { responder: options.responder } : {}),
726
961
  ...(monitor !== undefined ? { monitor } : {}),
@@ -735,7 +970,7 @@ export class JobManager {
735
970
  await this.persist();
736
971
  }
737
972
  catch {
738
- this.jobs.delete(id);
973
+ this.forgetJob(id);
739
974
  return {
740
975
  ok: false,
741
976
  output: "Background command launch error [PERSIST_FAILED]: could not persist the job registry; the command was not started.",
@@ -801,10 +1036,14 @@ export class JobManager {
801
1036
  launchError.syscall ? `syscall=${JSON.stringify(launchError.syscall)}` : undefined,
802
1037
  launchError.path ? `path=${JSON.stringify(launchError.path)}` : undefined,
803
1038
  ].filter((value) => Boolean(value));
1039
+ const ownership = job.responder
1040
+ ? "Responder owns this terminal launch failure; do not poll or retry it. Its result will be delivered automatically."
1041
+ : "Inspect the failed job receipt before deciding whether changed evidence supports a retry.";
804
1042
  const detail = `${launchRetried ? "Automatic retry after a transient launch ENOENT also failed.\n" : ""}` +
805
1043
  `Background command launch error [${code}]: ${launchError.message}\n` +
806
1044
  `${fields.join("; ")}\n` +
807
- "The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.";
1045
+ "The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.\n" +
1046
+ ownership;
808
1047
  stderr.append(`${detail}\n`);
809
1048
  await this.finalizeJob(job, "failed", { exitCode: 127 });
810
1049
  return {
@@ -817,6 +1056,7 @@ export class JobManager {
817
1056
  status: "failed",
818
1057
  exitCode: 127,
819
1058
  artifactPath: stdoutArtifact,
1059
+ ...(job.responder ? { responder: true } : {}),
820
1060
  nextOffset: 0,
821
1061
  },
822
1062
  };
@@ -831,17 +1071,23 @@ export class JobManager {
831
1071
  job.processGroupId = detached ? child.pid : undefined;
832
1072
  job.status = "running";
833
1073
  job.heartbeatAt = new Date().toISOString();
834
- job.processIdentity = processIdentity(child.pid);
1074
+ job.processIdentity = processIdentity(child.pid, { refresh: true });
835
1075
  this.processes.set(id, child);
836
1076
  this.writers.set(id, { stdout, stderr });
837
1077
  this.scheduleAuthorizationExpiry(job);
838
1078
  let lastProgressFlush = 0;
1079
+ let lastDurableFlush = Date.now();
839
1080
  let progressDirty = false;
840
1081
  let progressTimer;
1082
+ // UI freshness is cheap; rewriting the whole registry is not. Terminal
1083
+ // transitions always persist authoritatively below.
841
1084
  const flushProgress = () => {
842
1085
  progressDirty = false;
843
1086
  lastProgressFlush = Date.now();
844
- this.persistSync();
1087
+ if (lastProgressFlush - lastDurableFlush >= DURABLE_PROGRESS_FLUSH_MS) {
1088
+ lastDurableFlush = lastProgressFlush;
1089
+ this.persistSync();
1090
+ }
845
1091
  this.emit({ type: "job", jobId: id });
846
1092
  };
847
1093
  // Coalesce high-frequency stdout/stderr chunks: a chatty job (scanner,
@@ -937,10 +1183,13 @@ export class JobManager {
937
1183
  const earlyStatus = this.getJob(id)?.status;
938
1184
  if (earlyStatus === "failed") {
939
1185
  await this.persist();
1186
+ const failureFollowUp = job.responder
1187
+ ? "Responder owns this terminal failure; do not poll or retry it. Its result will be delivered automatically."
1188
+ : `Use shell.tail {"id":"${id}"} for captured stderr; do not retry unchanged.`;
940
1189
  return {
941
1190
  ok: false,
942
1191
  output: `Background job failed immediately: id=${id} exit=${job.exitCode ?? "?"}\n` +
943
- `Command: ${job.commandDisplay}\nUse shell.tail {"id":"${id}"} for captured stderr; do not retry unchanged.`,
1192
+ `Command: ${job.commandDisplay}\n${failureFollowUp}`,
944
1193
  exitCode: job.exitCode ?? 1,
945
1194
  outputPath: stdoutArtifact,
946
1195
  backgroundJob: {
@@ -948,6 +1197,7 @@ export class JobManager {
948
1197
  status: "failed",
949
1198
  exitCode: job.exitCode,
950
1199
  artifactPath: stdoutArtifact,
1200
+ ...(job.responder ? { responder: true } : {}),
951
1201
  nextOffset: 0,
952
1202
  },
953
1203
  };
@@ -957,14 +1207,14 @@ export class JobManager {
957
1207
  return {
958
1208
  ok: true,
959
1209
  output: `OS process launch confirmed: id=${id} (canonical job ID) pid=${child.pid ?? "?"}\n` +
960
- `This does not prove application readiness or continued liveness; use shell.tail {"id":"${id}"} and a readiness probe.\n` +
961
- `Use shell.jobs for status; do not use the artifact filename as the id.\n` +
962
- `Command: ${job.commandDisplay}\nArtifact: ${stdoutArtifact}`,
1210
+ `Command: ${job.commandDisplay}\nArtifact: ${stdoutArtifact}\n` +
1211
+ launchFollowUp(id, job.responder === true),
963
1212
  outputPath: stdoutArtifact,
964
1213
  backgroundJob: {
965
1214
  id,
966
1215
  status: job.status,
967
1216
  artifactPath: stdoutArtifact,
1217
+ ...(job.responder ? { responder: true } : {}),
968
1218
  ...(options?.profile ? { profile: options.profile } : {}),
969
1219
  ...(options?.estimatedSeconds !== undefined ? { estimatedSeconds: options.estimatedSeconds } : {}),
970
1220
  nextOffset: 0,
@@ -983,10 +1233,12 @@ export class JobManager {
983
1233
  this.persistSync();
984
1234
  this.emit({ type: "job", jobId: id });
985
1235
  }
1236
+ const followUp = launchFollowUp(id, job.responder === true);
986
1237
  return {
987
1238
  ok: alive,
988
1239
  output: `Background command launched as pid=${job.pid ?? "?"}, but job setup/persistence failed: ${detail}\n` +
989
- `${alive ? `The process is still running as job ${id}; do not launch a duplicate. Use shell.jobs/tail/stop.` : "The process is no longer running; inspect its artifacts before deciding whether to retry."}`,
1240
+ `${alive ? `The process is still running as job ${id}; do not launch a duplicate. ` : "The process has already stopped. "}` +
1241
+ followUp,
990
1242
  exitCode: alive ? undefined : job.exitCode,
991
1243
  outputPath: stdoutArtifact,
992
1244
  backgroundJob: {
@@ -994,6 +1246,7 @@ export class JobManager {
994
1246
  status: job.status,
995
1247
  exitCode: job.exitCode,
996
1248
  artifactPath: stdoutArtifact,
1249
+ ...(job.responder ? { responder: true } : {}),
997
1250
  nextOffset: 0,
998
1251
  },
999
1252
  };
@@ -1002,8 +1255,18 @@ export class JobManager {
1002
1255
  return {
1003
1256
  ok: false,
1004
1257
  output: `Background command launch error [UNKNOWN]: ${detail}\n` +
1005
- `cwd=${JSON.stringify(cwd)}\nThe command did not start.`,
1258
+ `cwd=${JSON.stringify(cwd)}\nThe command did not start.\n` +
1259
+ launchFollowUp(id, job.responder === true),
1006
1260
  exitCode: 127,
1261
+ outputPath: stdoutArtifact,
1262
+ backgroundJob: {
1263
+ id,
1264
+ status: "failed",
1265
+ exitCode: 127,
1266
+ artifactPath: stdoutArtifact,
1267
+ ...(job.responder ? { responder: true } : {}),
1268
+ nextOffset: 0,
1269
+ },
1007
1270
  };
1008
1271
  }
1009
1272
  }
@@ -1039,7 +1302,27 @@ export class JobManager {
1039
1302
  ? "alive"
1040
1303
  : "unresponsive"
1041
1304
  : "terminal";
1042
- return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
1305
+ const notification = this.notificationForJob(job.id);
1306
+ const receipt = notification
1307
+ ? notification.acknowledgedAt
1308
+ ? "consumed"
1309
+ : notification.analyzedAt
1310
+ ? "analyzed"
1311
+ : notification.readAt
1312
+ ? "read"
1313
+ : notification.deliveredAt
1314
+ ? "delivered-unread"
1315
+ : notification.deliveryStartedAt
1316
+ ? "delivering"
1317
+ : "ready"
1318
+ : this.consumedResponderResults.has(job.id)
1319
+ ? "consumed"
1320
+ : job.responder
1321
+ ? this.isLive(job)
1322
+ ? "running"
1323
+ : "pending"
1324
+ : undefined;
1325
+ return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"}${receipt ? ` responder=${receipt}` : ""} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
1043
1326
  };
1044
1327
  const header = sessionId
1045
1328
  ? `Session background jobs (${durable.length} total, session ${sessionId}):`
@@ -1082,6 +1365,7 @@ export class JobManager {
1082
1365
  getPendingNotifications(sessionId) {
1083
1366
  return [...this.notifications.values()]
1084
1367
  .filter((notification) => !notification.acknowledgedAt &&
1368
+ !notification.discardedAt &&
1085
1369
  (!sessionId || notification.ownerSessionId === sessionId))
1086
1370
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1087
1371
  }
@@ -1091,22 +1375,24 @@ export class JobManager {
1091
1375
  claimNextResponderNotification(sessionId, leaseId) {
1092
1376
  if (this.responderLeases.get(sessionId) !== leaseId)
1093
1377
  return undefined;
1094
- const archivedAt = new Date().toISOString();
1095
- let archived = false;
1378
+ let adopted = false;
1096
1379
  for (const notification of this.notifications.values()) {
1380
+ // Receipts belong to the session, not to a runtime lease: a new lease
1381
+ // adopts everything unread that an earlier lease left behind.
1097
1382
  if (notification.ownerSessionId === sessionId &&
1098
1383
  !notification.acknowledgedAt &&
1099
- notification.responderLeaseId !== leaseId &&
1100
- !notification.archivedAt) {
1101
- notification.archivedAt = archivedAt;
1102
- archived = true;
1384
+ !notification.archivedAt &&
1385
+ !notification.discardedAt &&
1386
+ notification.responderLeaseId !== leaseId) {
1387
+ notification.responderLeaseId = leaseId;
1388
+ adopted = true;
1103
1389
  }
1104
1390
  }
1105
1391
  const notification = [...this.notifications.values()]
1106
1392
  .filter((candidate) => candidate.ownerSessionId === sessionId &&
1107
1393
  candidate.responderLeaseId === leaseId &&
1108
1394
  !candidate.archivedAt &&
1109
- !candidate.deliveredAt &&
1395
+ !candidate.discardedAt &&
1110
1396
  !candidate.readAt &&
1111
1397
  !candidate.analyzedAt &&
1112
1398
  !candidate.acknowledgedAt &&
@@ -1114,26 +1400,53 @@ export class JobManager {
1114
1400
  .sort((left, right) => left.createdAt.localeCompare(right.createdAt))[0];
1115
1401
  if (notification)
1116
1402
  this.claimedNotifications.add(notification.id);
1117
- if (archived && !this.persistSync())
1403
+ if (adopted && !this.persistSync())
1118
1404
  this.scheduleRegistryRetry();
1119
1405
  return notification;
1120
1406
  }
1121
1407
  releaseResponderNotificationClaim(notificationId) {
1122
1408
  this.claimedNotifications.delete(notificationId);
1123
1409
  }
1124
- markDelivered(notificationId) {
1410
+ /**
1411
+ * Record that a delivery attempt started. This is deliberately weaker than
1412
+ * `markDelivered`: an aborted or failed analysis turn must remain deliverable.
1413
+ */
1414
+ markDeliveryStarted(notificationId, sessionId) {
1125
1415
  const notification = this.notifications.get(notificationId);
1126
- if (!notification)
1416
+ if (!this.ownsNotification(notification, sessionId))
1417
+ return false;
1418
+ if (notification.deliveryStartedAt)
1419
+ return true;
1420
+ notification.deliveryStartedAt = new Date().toISOString();
1421
+ if (!this.persistSync()) {
1422
+ notification.deliveryStartedAt = undefined;
1423
+ return false;
1424
+ }
1425
+ this.emit({
1426
+ type: "notification",
1427
+ jobId: notification.jobId,
1428
+ notificationId,
1429
+ });
1430
+ return true;
1431
+ }
1432
+ markDelivered(notificationId, sessionId) {
1433
+ const notification = this.notifications.get(notificationId);
1434
+ if (!this.ownsNotification(notification, sessionId))
1127
1435
  return false;
1128
1436
  if (!notification.deliveredAt) {
1129
1437
  notification.deliveredAt = new Date().toISOString();
1438
+ notification.deliveryStartedAt ??= notification.deliveredAt;
1130
1439
  if (!this.persistSync()) {
1131
1440
  notification.deliveredAt = undefined;
1132
1441
  return false;
1133
1442
  }
1134
1443
  this.emit({ type: "notification", jobId: notification.jobId, notificationId });
1135
1444
  }
1136
- this.claimedNotifications.delete(notificationId);
1445
+ if (notification.readAt ||
1446
+ notification.analyzedAt ||
1447
+ notification.acknowledgedAt) {
1448
+ this.claimedNotifications.delete(notificationId);
1449
+ }
1137
1450
  return true;
1138
1451
  }
1139
1452
  markRead(notificationId, sessionId) {
@@ -1141,7 +1454,8 @@ export class JobManager {
1141
1454
  if (!notification ||
1142
1455
  notification.ownerSessionId !== sessionId ||
1143
1456
  !notification.responder ||
1144
- notification.archivedAt) {
1457
+ notification.archivedAt ||
1458
+ notification.discardedAt) {
1145
1459
  return false;
1146
1460
  }
1147
1461
  const previousDeliveredAt = notification.deliveredAt;
@@ -1162,10 +1476,14 @@ export class JobManager {
1162
1476
  });
1163
1477
  return true;
1164
1478
  }
1165
- markAnalyzed(notificationId) {
1479
+ markAnalyzed(notificationId, sessionId) {
1166
1480
  const notification = this.notifications.get(notificationId);
1167
- if (!notification?.deliveredAt)
1481
+ if (!notification || !this.ownsNotification(notification, sessionId)) {
1168
1482
  return false;
1483
+ }
1484
+ if (!notification.deliveredAt && !notification.deliveryStartedAt) {
1485
+ return false;
1486
+ }
1169
1487
  this.claimedNotifications.delete(notificationId);
1170
1488
  if (!notification.analyzedAt) {
1171
1489
  notification.analyzedAt = new Date().toISOString();
@@ -1179,13 +1497,22 @@ export class JobManager {
1179
1497
  }
1180
1498
  return true;
1181
1499
  }
1182
- acknowledge(notificationId) {
1500
+ acknowledge(notificationId, sessionId) {
1183
1501
  const notification = this.notifications.get(notificationId);
1184
- if (!notification?.analyzedAt)
1502
+ if (!notification || !this.ownsNotification(notification, sessionId)) {
1503
+ return false;
1504
+ }
1505
+ if (!notification.analyzedAt)
1185
1506
  return false;
1186
1507
  this.claimedNotifications.delete(notificationId);
1187
1508
  if (!notification.acknowledgedAt) {
1188
1509
  notification.acknowledgedAt = new Date().toISOString();
1510
+ this.consumedResponderResults.set(notification.jobId, {
1511
+ jobId: notification.jobId,
1512
+ resultHash: notification.resultHash ?? "",
1513
+ resultRevision: notification.resultRevision ?? 1,
1514
+ acknowledgedAt: notification.acknowledgedAt,
1515
+ });
1189
1516
  const persisted = this.persistSync();
1190
1517
  if (!persisted)
1191
1518
  this.scheduleRegistryRetry();
@@ -1377,7 +1704,7 @@ export class JobManager {
1377
1704
  const identityMatches = () => {
1378
1705
  if (process.platform === "win32")
1379
1706
  return processAlive(pid);
1380
- const current = processIdentity(pid);
1707
+ const current = processIdentity(pid, { refresh: true });
1381
1708
  return Boolean(current && job.processIdentity && current === job.processIdentity);
1382
1709
  };
1383
1710
  if (targetAlive() && (!processAlive(pid) || !identityMatches())) {
@@ -1415,13 +1742,13 @@ export class JobManager {
1415
1742
  else if (!(processGroupId && processGroupVerified && allowVerifiedGroup)) {
1416
1743
  return "identity-mismatch";
1417
1744
  }
1418
- try {
1419
- processGroupId ? process.kill(-processGroupId, signal) : process.kill(pid, signal);
1420
- return "sent";
1421
- }
1422
- catch {
1745
+ const outcome = terminateProcessTree(pid, {
1746
+ signal,
1747
+ processGroupId,
1748
+ });
1749
+ if (outcome === "failed")
1423
1750
  return targetAlive() ? "failed" : "gone";
1424
- }
1751
+ return outcome;
1425
1752
  };
1426
1753
  const restoreRunning = () => {
1427
1754
  job.status = "running";
@@ -1536,15 +1863,16 @@ export class JobManager {
1536
1863
  acknowledgedAt: notification.acknowledgedAt,
1537
1864
  });
1538
1865
  notification.wakeOnCompletion = false;
1539
- notification.deliveredAt ??= acknowledgedAt;
1540
- notification.acknowledgedAt = acknowledgedAt;
1866
+ notification.discardedAt = acknowledgedAt;
1867
+ notification.discardReason = "session-cancelled";
1868
+ this.claimedNotifications.delete(notification.id);
1541
1869
  }
1542
1870
  const acknowledgementsPersisted = this.persistSync();
1543
1871
  if (!acknowledgementsPersisted) {
1544
1872
  for (const snapshot of notificationSnapshots) {
1545
1873
  snapshot.notification.wakeOnCompletion = snapshot.wakeOnCompletion;
1546
- snapshot.notification.deliveredAt = snapshot.deliveredAt;
1547
- snapshot.notification.acknowledgedAt = snapshot.acknowledgedAt;
1874
+ snapshot.notification.discardedAt = undefined;
1875
+ snapshot.notification.discardReason = undefined;
1548
1876
  }
1549
1877
  }
1550
1878
  else {
@@ -1611,21 +1939,26 @@ export class JobManager {
1611
1939
  if (timer)
1612
1940
  clearTimeout(timer);
1613
1941
  this.settlementTimers.delete(notification.jobId);
1614
- this.settlementAttempts.delete(notification.jobId);
1942
+ this.pendingSettlements.delete(notification.jobId);
1615
1943
  }
1616
1944
  for (const [id, notification] of this.notifications) {
1617
1945
  const job = this.jobs.get(notification.jobId);
1618
1946
  if (!job ||
1947
+ notification.discardedAt ||
1619
1948
  (notification.acknowledgedAt && job.status !== "lost")) {
1620
1949
  this.notifications.delete(id);
1621
1950
  }
1622
1951
  }
1952
+ for (const [jobId] of this.consumedResponderResults) {
1953
+ if (!this.jobs.has(jobId))
1954
+ this.consumedResponderResults.delete(jobId);
1955
+ }
1623
1956
  const durableTerminal = [];
1624
1957
  for (const [id, job] of this.jobs) {
1625
1958
  if (!this.isDurable(job)) {
1626
1959
  // Safety: never keep ephemeral rows that are not live.
1627
1960
  if (!this.isLive(job))
1628
- this.jobs.delete(id);
1961
+ this.forgetJob(id);
1629
1962
  continue;
1630
1963
  }
1631
1964
  if (this.isLive(job))
@@ -1638,7 +1971,7 @@ export class JobManager {
1638
1971
  }
1639
1972
  const ended = job.endedAt ? Date.parse(job.endedAt) : Date.parse(job.startedAt);
1640
1973
  if (Number.isFinite(ended) && now - ended > TERMINAL_JOB_MAX_AGE_MS) {
1641
- this.jobs.delete(id);
1974
+ this.forgetJob(id);
1642
1975
  continue;
1643
1976
  }
1644
1977
  durableTerminal.push(job);
@@ -1647,7 +1980,7 @@ export class JobManager {
1647
1980
  return;
1648
1981
  durableTerminal.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
1649
1982
  for (const job of durableTerminal.slice(MAX_DURABLE_TERMINAL_JOBS)) {
1650
- this.jobs.delete(job.id);
1983
+ this.forgetJob(job.id);
1651
1984
  }
1652
1985
  }
1653
1986
  loadAndReconcile() {
@@ -1667,6 +2000,23 @@ export class JobManager {
1667
2000
  catch { /* a corrupt current registry must not replay stale v1 state */ }
1668
2001
  if (!parsed)
1669
2002
  return;
2003
+ if (parsed.schemaVersion === 2 && Array.isArray(parsed.settlements)) {
2004
+ for (const settlement of parsed.settlements) {
2005
+ if (!settlement || typeof settlement.jobId !== "string")
2006
+ continue;
2007
+ this.pendingSettlements.set(settlement.jobId, settlement);
2008
+ }
2009
+ }
2010
+ if (parsed.schemaVersion === 2 && Array.isArray(parsed.consumedResults)) {
2011
+ for (const consumed of parsed.consumedResults) {
2012
+ if (!consumed ||
2013
+ typeof consumed.jobId !== "string" ||
2014
+ typeof consumed.resultHash !== "string") {
2015
+ continue;
2016
+ }
2017
+ this.consumedResponderResults.set(consumed.jobId, consumed);
2018
+ }
2019
+ }
1670
2020
  if (parsed.schemaVersion === 2 && Array.isArray(parsed.notifications)) {
1671
2021
  const notifiedJobs = new Set();
1672
2022
  for (const notification of parsed.notifications) {
@@ -1676,7 +2026,9 @@ export class JobManager {
1676
2026
  continue;
1677
2027
  notifiedJobs.add(notification.jobId);
1678
2028
  if (!notification.acknowledgedAt && !notification.archivedAt) {
1679
- notification.archivedAt = new Date().toISOString();
2029
+ // A durable receipt must survive the restart it exists for. Drop the
2030
+ // dead runtime lease so the next activated lease can adopt it.
2031
+ delete notification.responderLeaseId;
1680
2032
  }
1681
2033
  this.notifications.set(notification.id, notification);
1682
2034
  }
@@ -1713,6 +2065,7 @@ export class JobManager {
1713
2065
  if (!job?.responder)
1714
2066
  this.notifications.delete(id);
1715
2067
  }
2068
+ this.scheduleLivenessWatch();
1716
2069
  this.pruneTerminalJobs();
1717
2070
  this.persistSync();
1718
2071
  }
@@ -1724,6 +2077,12 @@ export class JobManager {
1724
2077
  schemaVersion: 2,
1725
2078
  jobs: [...this.jobs.values()].filter((job) => this.isDurable(job)),
1726
2079
  notifications: [...this.notifications.values()],
2080
+ ...(this.pendingSettlements.size
2081
+ ? { settlements: [...this.pendingSettlements.values()] }
2082
+ : {}),
2083
+ ...(this.consumedResponderResults.size
2084
+ ? { consumedResults: [...this.consumedResponderResults.values()] }
2085
+ : {}),
1727
2086
  };
1728
2087
  }
1729
2088
  scheduleRegistryRetry() {
@@ -1739,7 +2098,7 @@ export class JobManager {
1739
2098
  }
1740
2099
  persistSync() {
1741
2100
  try {
1742
- mkdirSync(this.jobsDir, { recursive: true });
2101
+ mkdirSync(this.jobsDir, { recursive: true, mode: 0o700 });
1743
2102
  const temp = `${this.registryPath}.${process.pid}.${randomUUID()}.tmp`;
1744
2103
  writeFileSync(temp, `${JSON.stringify(this.registry(), null, 2)}\n`, { mode: 0o600 });
1745
2104
  renameSync(temp, this.registryPath);