@raidiant/notifai 11.7.1 → 12.0.0-beta.13

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 (409) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/NOTICE +16 -0
  3. package/THIRD_PARTY_NOTICES +725 -0
  4. package/bin/notifai.mjs +11420 -0
  5. package/data/install.ps1 +318 -0
  6. package/data/release.json +1 -0
  7. package/inventory.json +1 -0
  8. package/npm-adapter-files.json +52 -0
  9. package/package.json +14 -44
  10. package/README.md +0 -168
  11. package/dist/agent-update-notice.d.ts +0 -7
  12. package/dist/agent-update-notice.js +0 -77
  13. package/dist/agent-update-notice.js.map +0 -1
  14. package/dist/atomic-file.d.ts +0 -30
  15. package/dist/atomic-file.js +0 -148
  16. package/dist/atomic-file.js.map +0 -1
  17. package/dist/attendant-update.d.ts +0 -15
  18. package/dist/attendant-update.js +0 -96
  19. package/dist/attendant-update.js.map +0 -1
  20. package/dist/claude-wake.d.ts +0 -108
  21. package/dist/claude-wake.js +0 -369
  22. package/dist/claude-wake.js.map +0 -1
  23. package/dist/cli-bin.d.ts +0 -26
  24. package/dist/cli-bin.js +0 -220
  25. package/dist/cli-bin.js.map +0 -1
  26. package/dist/cli-contract.d.ts +0 -16
  27. package/dist/cli-contract.js +0 -27
  28. package/dist/cli-contract.js.map +0 -1
  29. package/dist/cli-release.d.ts +0 -36
  30. package/dist/cli-release.js +0 -85
  31. package/dist/cli-release.js.map +0 -1
  32. package/dist/client.d.ts +0 -109
  33. package/dist/client.js +0 -217
  34. package/dist/client.js.map +0 -1
  35. package/dist/codex-answer-control.d.ts +0 -14
  36. package/dist/codex-answer-control.js +0 -41
  37. package/dist/codex-answer-control.js.map +0 -1
  38. package/dist/codex-answer-presentation.d.ts +0 -18
  39. package/dist/codex-answer-presentation.js +0 -113
  40. package/dist/codex-answer-presentation.js.map +0 -1
  41. package/dist/codex-input-lifecycle.d.ts +0 -10
  42. package/dist/codex-input-lifecycle.js +0 -63
  43. package/dist/codex-input-lifecycle.js.map +0 -1
  44. package/dist/codex-native-control.d.ts +0 -13
  45. package/dist/codex-native-control.js +0 -120
  46. package/dist/codex-native-control.js.map +0 -1
  47. package/dist/codex-native-turn.d.ts +0 -37
  48. package/dist/codex-native-turn.js +0 -155
  49. package/dist/codex-native-turn.js.map +0 -1
  50. package/dist/codex-question-bindings.d.ts +0 -76
  51. package/dist/codex-question-bindings.js +0 -151
  52. package/dist/codex-question-bindings.js.map +0 -1
  53. package/dist/codex-queue-control.d.ts +0 -21
  54. package/dist/codex-queue-control.js +0 -59
  55. package/dist/codex-queue-control.js.map +0 -1
  56. package/dist/codex-tool-messages.d.ts +0 -7
  57. package/dist/codex-tool-messages.js +0 -126
  58. package/dist/codex-tool-messages.js.map +0 -1
  59. package/dist/codex-wake.d.ts +0 -58
  60. package/dist/codex-wake.js +0 -135
  61. package/dist/codex-wake.js.map +0 -1
  62. package/dist/command-session.d.ts +0 -14
  63. package/dist/command-session.js +0 -31
  64. package/dist/command-session.js.map +0 -1
  65. package/dist/commands-acknowledge.d.ts +0 -13
  66. package/dist/commands-acknowledge.js +0 -116
  67. package/dist/commands-acknowledge.js.map +0 -1
  68. package/dist/commands-agent-sessions.d.ts +0 -6
  69. package/dist/commands-agent-sessions.js +0 -51
  70. package/dist/commands-agent-sessions.js.map +0 -1
  71. package/dist/commands-ask.d.ts +0 -62
  72. package/dist/commands-ask.js +0 -503
  73. package/dist/commands-ask.js.map +0 -1
  74. package/dist/commands-auth.d.ts +0 -76
  75. package/dist/commands-auth.js +0 -421
  76. package/dist/commands-auth.js.map +0 -1
  77. package/dist/commands-close.d.ts +0 -6
  78. package/dist/commands-close.js +0 -273
  79. package/dist/commands-close.js.map +0 -1
  80. package/dist/commands-config.d.ts +0 -31
  81. package/dist/commands-config.js +0 -272
  82. package/dist/commands-config.js.map +0 -1
  83. package/dist/commands-core.d.ts +0 -145
  84. package/dist/commands-core.js +0 -190
  85. package/dist/commands-core.js.map +0 -1
  86. package/dist/commands-devices.d.ts +0 -41
  87. package/dist/commands-devices.js +0 -144
  88. package/dist/commands-devices.js.map +0 -1
  89. package/dist/commands-doctor.d.ts +0 -25
  90. package/dist/commands-doctor.js +0 -1553
  91. package/dist/commands-doctor.js.map +0 -1
  92. package/dist/commands-guidance.d.ts +0 -27
  93. package/dist/commands-guidance.js +0 -150
  94. package/dist/commands-guidance.js.map +0 -1
  95. package/dist/commands-harness-context.d.ts +0 -57
  96. package/dist/commands-harness-context.js +0 -102
  97. package/dist/commands-harness-context.js.map +0 -1
  98. package/dist/commands-hook-attend.d.ts +0 -70
  99. package/dist/commands-hook-attend.js +0 -655
  100. package/dist/commands-hook-attend.js.map +0 -1
  101. package/dist/commands-hook-diagnostics.d.ts +0 -37
  102. package/dist/commands-hook-diagnostics.js +0 -136
  103. package/dist/commands-hook-diagnostics.js.map +0 -1
  104. package/dist/commands-hook-install.d.ts +0 -26
  105. package/dist/commands-hook-install.js +0 -784
  106. package/dist/commands-hook-install.js.map +0 -1
  107. package/dist/commands-hook-run.d.ts +0 -29
  108. package/dist/commands-hook-run.js +0 -933
  109. package/dist/commands-hook-run.js.map +0 -1
  110. package/dist/commands-hook-shape.d.ts +0 -19
  111. package/dist/commands-hook-shape.js +0 -48
  112. package/dist/commands-hook-shape.js.map +0 -1
  113. package/dist/commands-init.d.ts +0 -42
  114. package/dist/commands-init.js +0 -901
  115. package/dist/commands-init.js.map +0 -1
  116. package/dist/commands-io.d.ts +0 -2
  117. package/dist/commands-io.js +0 -117
  118. package/dist/commands-io.js.map +0 -1
  119. package/dist/commands-logs.d.ts +0 -30
  120. package/dist/commands-logs.js +0 -201
  121. package/dist/commands-logs.js.map +0 -1
  122. package/dist/commands-native-acknowledge.d.ts +0 -8
  123. package/dist/commands-native-acknowledge.js +0 -114
  124. package/dist/commands-native-acknowledge.js.map +0 -1
  125. package/dist/commands-project.d.ts +0 -4
  126. package/dist/commands-project.js +0 -41
  127. package/dist/commands-project.js.map +0 -1
  128. package/dist/commands-receive.d.ts +0 -3
  129. package/dist/commands-receive.js +0 -59
  130. package/dist/commands-receive.js.map +0 -1
  131. package/dist/commands-send-support.d.ts +0 -59
  132. package/dist/commands-send-support.js +0 -258
  133. package/dist/commands-send-support.js.map +0 -1
  134. package/dist/commands-send.d.ts +0 -34
  135. package/dist/commands-send.js +0 -900
  136. package/dist/commands-send.js.map +0 -1
  137. package/dist/commands-setup-proof.d.ts +0 -43
  138. package/dist/commands-setup-proof.js +0 -134
  139. package/dist/commands-setup-proof.js.map +0 -1
  140. package/dist/commands-skill.d.ts +0 -21
  141. package/dist/commands-skill.js +0 -199
  142. package/dist/commands-skill.js.map +0 -1
  143. package/dist/commands-sounds.d.ts +0 -12
  144. package/dist/commands-sounds.js +0 -35
  145. package/dist/commands-sounds.js.map +0 -1
  146. package/dist/commands-update-check.d.ts +0 -6
  147. package/dist/commands-update-check.js +0 -102
  148. package/dist/commands-update-check.js.map +0 -1
  149. package/dist/commands-update-resume.d.ts +0 -7
  150. package/dist/commands-update-resume.js +0 -143
  151. package/dist/commands-update-resume.js.map +0 -1
  152. package/dist/commands-update-skill.d.ts +0 -5
  153. package/dist/commands-update-skill.js +0 -36
  154. package/dist/commands-update-skill.js.map +0 -1
  155. package/dist/commands-update.d.ts +0 -11
  156. package/dist/commands-update.js +0 -286
  157. package/dist/commands-update.js.map +0 -1
  158. package/dist/commands.d.ts +0 -26
  159. package/dist/commands.js +0 -27
  160. package/dist/commands.js.map +0 -1
  161. package/dist/config-schema.d.ts +0 -62
  162. package/dist/config-schema.js +0 -302
  163. package/dist/config-schema.js.map +0 -1
  164. package/dist/config.d.ts +0 -181
  165. package/dist/config.js +0 -357
  166. package/dist/config.js.map +0 -1
  167. package/dist/credentials.d.ts +0 -96
  168. package/dist/credentials.js +0 -342
  169. package/dist/credentials.js.map +0 -1
  170. package/dist/file-lock.d.ts +0 -34
  171. package/dist/file-lock.js +0 -367
  172. package/dist/file-lock.js.map +0 -1
  173. package/dist/generated-session-label.d.ts +0 -2
  174. package/dist/generated-session-label.js +0 -152
  175. package/dist/generated-session-label.js.map +0 -1
  176. package/dist/guidance-content.d.ts +0 -53
  177. package/dist/guidance-content.js +0 -248
  178. package/dist/guidance-content.js.map +0 -1
  179. package/dist/guidance-render.d.ts +0 -25
  180. package/dist/guidance-render.js +0 -47
  181. package/dist/guidance-render.js.map +0 -1
  182. package/dist/guidance.d.ts +0 -69
  183. package/dist/guidance.js +0 -173
  184. package/dist/guidance.js.map +0 -1
  185. package/dist/harness-session-title.d.ts +0 -22
  186. package/dist/harness-session-title.js +0 -74
  187. package/dist/harness-session-title.js.map +0 -1
  188. package/dist/harnesses.d.ts +0 -65
  189. package/dist/harnesses.js +0 -159
  190. package/dist/harnesses.js.map +0 -1
  191. package/dist/hermes-attendant.d.ts +0 -38
  192. package/dist/hermes-attendant.js +0 -337
  193. package/dist/hermes-attendant.js.map +0 -1
  194. package/dist/hermes-plugin.d.ts +0 -11
  195. package/dist/hermes-plugin.js +0 -299
  196. package/dist/hermes-plugin.js.map +0 -1
  197. package/dist/hook-acknowledgements.d.ts +0 -70
  198. package/dist/hook-acknowledgements.js +0 -490
  199. package/dist/hook-acknowledgements.js.map +0 -1
  200. package/dist/hook-adapter.d.ts +0 -48
  201. package/dist/hook-adapter.js +0 -419
  202. package/dist/hook-adapter.js.map +0 -1
  203. package/dist/hook-events.d.ts +0 -105
  204. package/dist/hook-events.js +0 -130
  205. package/dist/hook-events.js.map +0 -1
  206. package/dist/hook-gates.d.ts +0 -7
  207. package/dist/hook-gates.js +0 -20
  208. package/dist/hook-gates.js.map +0 -1
  209. package/dist/hook-input.d.ts +0 -10
  210. package/dist/hook-input.js +0 -64
  211. package/dist/hook-input.js.map +0 -1
  212. package/dist/hook-lifecycle.d.ts +0 -112
  213. package/dist/hook-lifecycle.js +0 -2031
  214. package/dist/hook-lifecycle.js.map +0 -1
  215. package/dist/hook-project-sessions.d.ts +0 -28
  216. package/dist/hook-project-sessions.js +0 -141
  217. package/dist/hook-project-sessions.js.map +0 -1
  218. package/dist/hook-question-lock.d.ts +0 -29
  219. package/dist/hook-question-lock.js +0 -217
  220. package/dist/hook-question-lock.js.map +0 -1
  221. package/dist/hook-question-retirement.d.ts +0 -74
  222. package/dist/hook-question-retirement.js +0 -314
  223. package/dist/hook-question-retirement.js.map +0 -1
  224. package/dist/hook-question-state.d.ts +0 -58
  225. package/dist/hook-question-state.js +0 -239
  226. package/dist/hook-question-state.js.map +0 -1
  227. package/dist/hook-session-state.d.ts +0 -127
  228. package/dist/hook-session-state.js +0 -488
  229. package/dist/hook-session-state.js.map +0 -1
  230. package/dist/hook-types.d.ts +0 -476
  231. package/dist/hook-types.js +0 -4
  232. package/dist/hook-types.js.map +0 -1
  233. package/dist/injection-render.d.ts +0 -36
  234. package/dist/injection-render.js +0 -61
  235. package/dist/injection-render.js.map +0 -1
  236. package/dist/install-hooks.d.ts +0 -470
  237. package/dist/install-hooks.js +0 -1622
  238. package/dist/install-hooks.js.map +0 -1
  239. package/dist/integration-health.d.ts +0 -26
  240. package/dist/integration-health.js +0 -133
  241. package/dist/integration-health.js.map +0 -1
  242. package/dist/interactive.d.ts +0 -22
  243. package/dist/interactive.js +0 -644
  244. package/dist/interactive.js.map +0 -1
  245. package/dist/invocation-context.d.ts +0 -47
  246. package/dist/invocation-context.js +0 -164
  247. package/dist/invocation-context.js.map +0 -1
  248. package/dist/local-path.d.ts +0 -7
  249. package/dist/local-path.js +0 -27
  250. package/dist/local-path.js.map +0 -1
  251. package/dist/logging.d.ts +0 -157
  252. package/dist/logging.js +0 -599
  253. package/dist/logging.js.map +0 -1
  254. package/dist/main-run.d.ts +0 -1
  255. package/dist/main-run.js +0 -42
  256. package/dist/main-run.js.map +0 -1
  257. package/dist/main.d.ts +0 -2
  258. package/dist/main.js +0 -12
  259. package/dist/main.js.map +0 -1
  260. package/dist/native-answer-operation.d.ts +0 -70
  261. package/dist/native-answer-operation.js +0 -247
  262. package/dist/native-answer-operation.js.map +0 -1
  263. package/dist/native-skills.d.ts +0 -64
  264. package/dist/native-skills.js +0 -167
  265. package/dist/native-skills.js.map +0 -1
  266. package/dist/node-floor.d.ts +0 -19
  267. package/dist/node-floor.js +0 -32
  268. package/dist/node-floor.js.map +0 -1
  269. package/dist/npm-invocation.d.ts +0 -34
  270. package/dist/npm-invocation.js +0 -53
  271. package/dist/npm-invocation.js.map +0 -1
  272. package/dist/openclaw-continuation-bridge.d.ts +0 -7
  273. package/dist/openclaw-continuation-bridge.js +0 -64
  274. package/dist/openclaw-continuation-bridge.js.map +0 -1
  275. package/dist/openclaw-gateway-readiness.d.ts +0 -1
  276. package/dist/openclaw-gateway-readiness.js +0 -24
  277. package/dist/openclaw-gateway-readiness.js.map +0 -1
  278. package/dist/openclaw-generation.d.ts +0 -21
  279. package/dist/openclaw-generation.js +0 -115
  280. package/dist/openclaw-generation.js.map +0 -1
  281. package/dist/openclaw-message-bridge.d.ts +0 -8
  282. package/dist/openclaw-message-bridge.js +0 -93
  283. package/dist/openclaw-message-bridge.js.map +0 -1
  284. package/dist/openclaw-pending.d.ts +0 -8
  285. package/dist/openclaw-pending.js +0 -47
  286. package/dist/openclaw-pending.js.map +0 -1
  287. package/dist/openclaw-plugin.d.ts +0 -75
  288. package/dist/openclaw-plugin.js +0 -1353
  289. package/dist/openclaw-plugin.js.map +0 -1
  290. package/dist/openclaw-session-access.d.ts +0 -12
  291. package/dist/openclaw-session-access.js +0 -79
  292. package/dist/openclaw-session-access.js.map +0 -1
  293. package/dist/opencode-plugin.d.ts +0 -88
  294. package/dist/opencode-plugin.js +0 -286
  295. package/dist/opencode-plugin.js.map +0 -1
  296. package/dist/orca-session-title.d.ts +0 -11
  297. package/dist/orca-session-title.js +0 -111
  298. package/dist/orca-session-title.js.map +0 -1
  299. package/dist/pack-feedback-slice.d.ts +0 -35
  300. package/dist/pack-feedback-slice.js +0 -126
  301. package/dist/pack-feedback-slice.js.map +0 -1
  302. package/dist/pairing-qr.d.ts +0 -5
  303. package/dist/pairing-qr.js +0 -24
  304. package/dist/pairing-qr.js.map +0 -1
  305. package/dist/pending-pairing.d.ts +0 -38
  306. package/dist/pending-pairing.js +0 -70
  307. package/dist/pending-pairing.js.map +0 -1
  308. package/dist/platform.d.ts +0 -67
  309. package/dist/platform.js +0 -140
  310. package/dist/platform.js.map +0 -1
  311. package/dist/process-identity.d.ts +0 -26
  312. package/dist/process-identity.js +0 -82
  313. package/dist/process-identity.js.map +0 -1
  314. package/dist/program.d.ts +0 -59
  315. package/dist/program.js +0 -691
  316. package/dist/program.js.map +0 -1
  317. package/dist/project-enablement.d.ts +0 -21
  318. package/dist/project-enablement.js +0 -71
  319. package/dist/project-enablement.js.map +0 -1
  320. package/dist/question-settlement-process.d.ts +0 -17
  321. package/dist/question-settlement-process.js +0 -35
  322. package/dist/question-settlement-process.js.map +0 -1
  323. package/dist/question-timing.d.ts +0 -46
  324. package/dist/question-timing.js +0 -48
  325. package/dist/question-timing.js.map +0 -1
  326. package/dist/readiness.d.ts +0 -153
  327. package/dist/readiness.js +0 -149
  328. package/dist/readiness.js.map +0 -1
  329. package/dist/release.d.ts +0 -31
  330. package/dist/release.js +0 -52
  331. package/dist/release.js.map +0 -1
  332. package/dist/send-attempts.d.ts +0 -26
  333. package/dist/send-attempts.js +0 -139
  334. package/dist/send-attempts.js.map +0 -1
  335. package/dist/send.d.ts +0 -115
  336. package/dist/send.js +0 -421
  337. package/dist/send.js.map +0 -1
  338. package/dist/session-activation.d.ts +0 -13
  339. package/dist/session-activation.js +0 -64
  340. package/dist/session-activation.js.map +0 -1
  341. package/dist/session-attendant-probe.d.ts +0 -85
  342. package/dist/session-attendant-probe.js +0 -173
  343. package/dist/session-attendant-probe.js.map +0 -1
  344. package/dist/session-attendant-state.d.ts +0 -70
  345. package/dist/session-attendant-state.js +0 -256
  346. package/dist/session-attendant-state.js.map +0 -1
  347. package/dist/session-attendant.d.ts +0 -143
  348. package/dist/session-attendant.js +0 -520
  349. package/dist/session-attendant.js.map +0 -1
  350. package/dist/session-delivery.d.ts +0 -249
  351. package/dist/session-delivery.js +0 -576
  352. package/dist/session-delivery.js.map +0 -1
  353. package/dist/session-handoff.d.ts +0 -98
  354. package/dist/session-handoff.js +0 -118
  355. package/dist/session-handoff.js.map +0 -1
  356. package/dist/session-input-wakes.d.ts +0 -78
  357. package/dist/session-input-wakes.js +0 -239
  358. package/dist/session-input-wakes.js.map +0 -1
  359. package/dist/session-inputs.d.ts +0 -57
  360. package/dist/session-inputs.js +0 -434
  361. package/dist/session-inputs.js.map +0 -1
  362. package/dist/session-labels.d.ts +0 -56
  363. package/dist/session-labels.js +0 -396
  364. package/dist/session-labels.js.map +0 -1
  365. package/dist/session-message-handoff.d.ts +0 -39
  366. package/dist/session-message-handoff.js +0 -107
  367. package/dist/session-message-handoff.js.map +0 -1
  368. package/dist/setup-destinations.d.ts +0 -23
  369. package/dist/setup-destinations.js +0 -44
  370. package/dist/setup-destinations.js.map +0 -1
  371. package/dist/skill-integrity.d.ts +0 -54
  372. package/dist/skill-integrity.js +0 -164
  373. package/dist/skill-integrity.js.map +0 -1
  374. package/dist/skill-source/manifest.json +0 -40
  375. package/dist/skill-source/notifai/SKILL.md +0 -350
  376. package/dist/skill-source/notifai/references/diagnostics.md +0 -36
  377. package/dist/skill-source/notifai/references/harness-setup.md +0 -384
  378. package/dist/skill-source/notifai/references/native-questions.md +0 -51
  379. package/dist/skill-source/notifai/references/notes-and-edits.md +0 -54
  380. package/dist/skill-source/notifai/references/send-details.md +0 -14
  381. package/dist/skill-source/notifai/references/updates.md +0 -116
  382. package/dist/skill-source/notifai/references/writing-examples.md +0 -56
  383. package/dist/sound-ref.d.ts +0 -23
  384. package/dist/sound-ref.js +0 -28
  385. package/dist/sound-ref.js.map +0 -1
  386. package/dist/ui/banner.d.ts +0 -22
  387. package/dist/ui/banner.js +0 -87
  388. package/dist/ui/banner.js.map +0 -1
  389. package/dist/ui/config-view.d.ts +0 -30
  390. package/dist/ui/config-view.js +0 -108
  391. package/dist/ui/config-view.js.map +0 -1
  392. package/dist/ui/help.d.ts +0 -44
  393. package/dist/ui/help.js +0 -107
  394. package/dist/ui/help.js.map +0 -1
  395. package/dist/ui/theme.d.ts +0 -90
  396. package/dist/ui/theme.js +0 -244
  397. package/dist/ui/theme.js.map +0 -1
  398. package/dist/update-handoff.d.ts +0 -29
  399. package/dist/update-handoff.js +0 -69
  400. package/dist/update-handoff.js.map +0 -1
  401. package/dist/url-policy.d.ts +0 -85
  402. package/dist/url-policy.js +0 -369
  403. package/dist/url-policy.js.map +0 -1
  404. package/dist/version.d.ts +0 -40
  405. package/dist/version.js +0 -132
  406. package/dist/version.js.map +0 -1
  407. package/dist/wake-support.d.ts +0 -40
  408. package/dist/wake-support.js +0 -95
  409. package/dist/wake-support.js.map +0 -1
@@ -1,2031 +0,0 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { confirmNativeAnswerTarget, recordConfirmedNativeAnswerTarget } from './native-answer-operation.js';
3
- import { markCodexOrdinaryPresentation, mayRetireFromPrompt, reserveCurrentCodexQuestion } from './codex-question-bindings.js';
4
- import { ApiCallError, isRetryableReplyPollError } from './client.js';
5
- import { withFileLock } from './file-lock.js';
6
- import { HARNESS_CAPABILITIES } from './harnesses.js';
7
- import { gate } from './hook-gates.js';
8
- import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, classifyJournaledAcknowledgements, finishCommittedDelivery, holdForAcknowledgement, owedAcknowledgements, observeQueuedMessagePrompt, presentedMessageAcknowledgements, reconcileAcknowledgementObligations, recoverQueuedAnswers, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
9
- import { clearMatchingProjectSession, writeProjectSession } from './hook-project-sessions.js';
10
- import { acquireClaimFile, releaseClaimFile, claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
11
- import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, selectedForClaimedDelivery, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
12
- import { admitQueuedQuestion, replaceFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, questionSubmissionAttempted, queuedQuestionStillEligible, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
13
- import { clearSessionStateUnlocked, markSessionEnded, pendingList, readSessionState, readSessionIncarnation, readSessionEndMarker, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
14
- import { currentProcessIdentity, processIdentityLiveness } from './process-identity.js';
15
- import { userPromptContextOutput } from './session-activation.js';
16
- import { answersAlreadyWritten, beginHandOff, recordUnclaimedHandOffs, recoverDeliveryJournal, sweepDeliveryJournals, } from './session-delivery.js';
17
- import { WRITE_ABORTED_REASON } from './wake-support.js';
18
- import { QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
19
- import { buildDraft } from './send.js';
20
- /**
21
- * The wire contract's shortest reply window. A question submitted with less
22
- * than this is rejected outright, and accepting one would in any case let the
23
- * server go on taking an answer after the waiter that owns it has returned.
24
- */
25
- export const MIN_REPLY_WINDOW_SECONDS = 60;
26
- /**
27
- * The terminal-first wait: the question sits in the terminal for
28
- * `ask_grace_seconds` from when it was sent, and only then reaches companion
29
- * devices.
30
- *
31
- * A plain timer, and nothing else. It once polled an idle signal so it could
32
- * abandon the wait the moment the user touched the keyboard — necessary while
33
- * the wait blocked the terminal, because a user wanting to answer locally was
34
- * otherwise locked out of their own prompt. Presence no longer changes this
35
- * deterministic timer; whether the harness holds or wakes is a separate route
36
- * decision.
37
- *
38
- * The window is measured from registration, not from the turn's end: a
39
- * question the agent asked five minutes ago while it kept working has already
40
- * served its wait. A stamp from the future would otherwise consume the whole
41
- * ceiling and leave no room to answer, so it starts no later than now.
42
- */
43
- async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
44
- const graceSeconds = ctx.config.ask_grace_seconds.value;
45
- if (graceSeconds <= 0)
46
- return;
47
- const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt);
48
- const remaining = until - ctx.now();
49
- if (remaining > 0)
50
- await ctx.sleep(remaining);
51
- }
52
- /**
53
- * A question is stored so a later hook can push it, and it reaches us from a
54
- * shell command, so its size is whatever the agent typed. The push itself is
55
- * bounded by the current 4096-byte push-provider envelopes; this bounds what
56
- * sits on disk in the meantime, and keeps one runaway agent from writing
57
- * megabytes per session.
58
- */
59
- const MAX_STORED_QUESTION_CHARS = 2000;
60
- /**
61
- * One round of the waiter's reply poll. Short enough that several questions
62
- * still share one wall clock, long enough not to hammer the server.
63
- */
64
- const REPLY_POLL_SECONDS = 5;
65
- /** Avoid hot-looping while a live observer cooperatively yields its claim. */
66
- const CLAIM_HANDOFF_POLL_MS = 1_000;
67
- /**
68
- * Push a question and block for the answer.
69
- *
70
- * An answered question is closed the moment this wait sees the answer: the
71
- * first device to answer claims the question and the others are told. Until
72
- * that close, a later reply is a correction — so what this returns as "the"
73
- * answer is always the latest reply, with the full stream alongside for
74
- * free-text answers given in parts. An unanswered question stays open,
75
- * because nobody has acted on the silence yet and the next turn can still
76
- * collect the answer with `notifai replies`.
77
- */
78
- /**
79
- * Put one registered question on the user's devices. Submission only — the
80
- * wait happens once, across every live question, in the caller.
81
- */
82
- async function prepareQuestionSubmission(ctx, options) {
83
- const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
84
- // A draft carrying `reply` is rejected outright if it targets a device that
85
- // cannot answer, so resolve the healthy companion platforms explicitly.
86
- const answerable = await answerableDevices(ctx);
87
- if (answerable.length === 0) {
88
- return { error: 'no device can answer a question yet; leaving this to the terminal' };
89
- }
90
- const build = buildDraft(ctx.config, {
91
- title: options.questions[0].text,
92
- summary: options.summary,
93
- ...(options.body !== undefined ? { body: options.body } : {}),
94
- lifecycle: { tier: 'needs_you' },
95
- ...(options.project !== undefined ? { project: options.project } : {}),
96
- ...(options.media !== undefined ? { media: options.media } : {}),
97
- device: answerable,
98
- reply: true,
99
- replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
100
- questions: options.questions,
101
- collapseKey,
102
- }, options.source === undefined ? {} : { source: options.source });
103
- if (!build.ok)
104
- return { error: build.error };
105
- return {
106
- request_id: `req_${randomBytes(18).toString('base64url')}`,
107
- idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
108
- collapse_key: collapseKey,
109
- device_ids: answerable,
110
- draft: build.draft,
111
- owner_deadline_at: options.ownerDeadlineAt,
112
- };
113
- }
114
- function submitQuestion(ctx, sessionId, entry, intent, notes) {
115
- try {
116
- const attempt = admitQueuedQuestion(sessionId, ctx.env, entry, intent, ctx.now(), async () => {
117
- const receipt = await ctx.client.submit({
118
- request_id: intent.request_id,
119
- idempotency_key: intent.idempotency_key,
120
- draft: intent.draft,
121
- }, 0);
122
- if (receipt.request_id !== intent.request_id) {
123
- throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
124
- }
125
- recordConfirmedNativeAnswerTarget(sessionId, ctx.env, { ...entry, request_id: receipt.request_id });
126
- // Wakes this session's dormant Session Attendant.
127
- recordSessionNotified(sessionId, ctx.env, ctx.now());
128
- return receipt;
129
- }, ctx.service_identity);
130
- if (attempt === null)
131
- notes.push('the question was retired or its Approved Machine changed before submission; not uploading it');
132
- return attempt;
133
- }
134
- catch (err) {
135
- // Only synchronous local admission failed. The callback returns a promise,
136
- // so transport failures stay on the caller's ambiguous-response path.
137
- notes.push(`could not admit the queued question; preserving it without upload: ${String(err)}`);
138
- return null;
139
- }
140
- }
141
- function canSubmitCompleteWindow(ctx, intent, fallbackWindowSeconds) {
142
- const frozenWindowSeconds = intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds;
143
- return (intent.owner_deadline_at - ctx.now() >=
144
- (frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000);
145
- }
146
- /**
147
- * The waiter's wait: one bounded poll across every live question at once.
148
- *
149
- * Each round polls all of them concurrently, so several questions cost the
150
- * same wall clock as one; the first round that finds any reply returns
151
- * everything found in that round, and whatever was not answered stays
152
- * registered. A question whose polling is permanently rejected drops out of
153
- * the round rather than failing the whole wait.
154
- */
155
- async function waitForAnyReply(ctx, requestIds, timeoutSeconds, interruption) {
156
- const deadline = ctx.now() + timeoutSeconds * 1000;
157
- let degraded = false;
158
- let firstPoll = true;
159
- const permanentFailures = new Map();
160
- if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
161
- return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null };
162
- }
163
- for (;;) {
164
- const beforePoll = interruption?.() ?? null;
165
- if (beforePoll !== null) {
166
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll };
167
- }
168
- const remainingMs = Math.max(0, deadline - ctx.now());
169
- if (!firstPoll && remainingMs === 0) {
170
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: null };
171
- }
172
- firstPoll = false;
173
- const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
174
- const results = await Promise.all(requestIds
175
- .filter((requestId) => !permanentFailures.has(requestId))
176
- .map(async (requestId) => {
177
- try {
178
- return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) };
179
- }
180
- catch (err) {
181
- if (isRetryableReplyPollError(err)) {
182
- return { requestId, replies: [], degraded: true };
183
- }
184
- return {
185
- requestId,
186
- replies: [],
187
- degraded: false,
188
- permanentFailure: replyPollFailure(err),
189
- };
190
- }
191
- }));
192
- const byRequest = new Map();
193
- for (const result of results) {
194
- degraded ||= result.degraded === true;
195
- if (result.permanentFailure !== undefined) {
196
- permanentFailures.set(result.requestId, result.permanentFailure);
197
- }
198
- if (result.replies.length > 0)
199
- byRequest.set(result.requestId, result.replies);
200
- }
201
- const afterPoll = interruption?.() ?? null;
202
- // SessionEnd or terminal-side retirement wins even if an in-flight poll
203
- // happened to return an answer. A newly registered question merely asks
204
- // this owner to yield; an answer already observed remains authoritative.
205
- if (afterPoll === 'ownership-ended') {
206
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
207
- }
208
- if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
209
- return { byRequest, degraded, permanentFailures, interrupted: null };
210
- }
211
- if (afterPoll === 'new-question') {
212
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
213
- }
214
- }
215
- }
216
- /**
217
- * Local lifecycle signal for a long-lived observer.
218
- *
219
- * SessionEnd and terminal-side retirement remove an owned request from pending
220
- * state; the detached process must stop instead of recreating that session.
221
- * A newly registered or submitted question asks a native observer to hand off
222
- * to an owner whose lifetime covers the later answer window.
223
- */
224
- function waiterInterruption(sessionId, env, requestIds, attemptedLocal) {
225
- if (sessionHasEnded(sessionId, env))
226
- return 'ownership-ended';
227
- const pending = pendingList(readSessionState(sessionId, env));
228
- const owned = new Set(requestIds);
229
- // One question can be retired from the terminal while this observer owns
230
- // several. That is a per-question change, not whole-session cancellation;
231
- // the caller filters the changed set after each poll and keeps the rest.
232
- return pending.some((entry) => entry.request_id !== undefined ? !owned.has(entry.request_id) :
233
- !attemptedLocal.has(entry.question_id ?? entry.question))
234
- ? 'new-question'
235
- : null;
236
- }
237
- /** A non-retryable replies fault, with enough server identity to act on it. */
238
- function replyPollFailure(err) {
239
- return err instanceof ApiCallError
240
- ? `${err.code} (HTTP ${err.status}): ${err.message}`
241
- : String(err);
242
- }
243
- function permanentReplyFailureNote(failures) {
244
- if (failures.size === 0)
245
- return null;
246
- const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ');
247
- return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`;
248
- }
249
- /** Healthy Companion devices that advertise the current answer job. */
250
- async function answerableDevices(ctx) {
251
- const configured = ctx.config.devices.value;
252
- const { devices } = await ctx.client.listDevices();
253
- return devices
254
- .filter((device) => (device.platform === 'ios' ||
255
- device.platform === 'macos' ||
256
- device.platform === 'android') &&
257
- device.registration_healthy &&
258
- device.capabilities?.includes('answer') === true &&
259
- device.derived_status !== 'must_update')
260
- .filter((device) => configured === null || configured.includes(device.device_id))
261
- .map((device) => device.device_id);
262
- }
263
- const LATE_STOP_POLL_SECONDS = 4;
264
- const LATE_PROMPT_POLL_SECONDS = 3;
265
- /**
266
- * The one bound on answer deliveries, for every route.
267
- *
268
- * It caps two things that both used to be able to run away: how many times a
269
- * single accepted answer may be handed to a route before the journal gives up
270
- * on it, and how many answer-driven continuation generations one chain may
271
- * produce.
272
- */
273
- export const MAX_CONTINUATION_COUNT = 3;
274
- /**
275
- * How many turns an answer may be *held* before it is settled unread.
276
- *
277
- * A hold is not a delivery: the route reported that it handed nothing over, so
278
- * the agent has not seen the answer and the wake-loop the continuation cap
279
- * exists to stop has not happened. Counting holds against that cap meant three
280
- * turns where a liveness probe could not reach a busy session were enough to
281
- * discard an answer the user had already given.
282
- *
283
- * It still has to end. A route that can never deliver would otherwise retry
284
- * every turn for the life of the session, so holds get their own, far looser
285
- * bound — loose because each one costs nothing and the answer is still wanted.
286
- */
287
- export const MAX_HELD_DELIVERIES = 20;
288
- /** One bounded recovery poll for a question that outlived its original wait. */
289
- async function pollPendingReply(ctx, pending, timeoutSeconds) {
290
- if (pending.request_id === undefined) {
291
- return { reply: null, replies: [], degraded: false, permanentFailure: null };
292
- }
293
- try {
294
- const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
295
- return {
296
- reply: result.replies.at(-1) ?? null,
297
- replies: result.replies,
298
- degraded: result.degraded === true,
299
- permanentFailure: null,
300
- };
301
- }
302
- catch (err) {
303
- return isRetryableReplyPollError(err)
304
- ? { reply: null, replies: [], degraded: true, permanentFailure: null }
305
- : { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) };
306
- }
307
- }
308
- /**
309
- * How an answer-collecting close names itself: `deliver` only when this
310
- * waiter will claim the fenced answer before writing it in place — a Claude
311
- * inbox write or a Codex thread-queue write while this session's attendant
312
- * holds a lease. Otherwise no disposition, exactly as released CLIs close.
313
- */
314
- function answerCloseDisposition(ctx, route) {
315
- // OpenClaw's Gateway service starts the attendant before it submits the
316
- // question, but an immediate reply can beat the first lease exchange. The
317
- // service retains the exact-session owner and retries after the lease is
318
- // ready, so preserve the server's selected delivery claim at close.
319
- if ((ctx.harness === 'openclaw' || ctx.harness === 'hermes') && route.kind === 'session-queue')
320
- return 'deliver';
321
- return claimableRoute(route) && ctx.answerClaims?.lease() != null ? 'deliver' : undefined;
322
- }
323
- /** Routes that write into the running session itself, so a claim can cover the write. */
324
- function claimableRoute(route) {
325
- return route.kind === 'inbox-socket' || route.kind === 'session-queue';
326
- }
327
- /** The claim marker for an answer its own `deliver` close selected. */
328
- function claimMarker(response) {
329
- return selectedForClaimedDelivery(response) ? { delivery_claim: true } : {};
330
- }
331
- /** Close several windows concurrently without confusing failure with silence. */
332
- async function finalizePendings(ctx, sessionId, pending, disposition) {
333
- return Promise.all(pending.map(async (entry) => {
334
- const response = await finalizeReplies(ctx, entry.request_id, disposition);
335
- if (response !== null && response.request_id === entry.request_id) {
336
- recordConfirmedNativeAnswerTarget(sessionId, ctx.env, entry);
337
- }
338
- ctx.log?.info('hook.retirement', {
339
- request_id: entry.request_id,
340
- attempted: true,
341
- proven: response !== null,
342
- });
343
- return { pending: entry, response };
344
- }));
345
- }
346
- /** Convert a fenced final reply set into the exact answer handed to the model. */
347
- function finalizedAnswer(finalized) {
348
- const replies = finalized.response?.replies ?? [];
349
- const reply = replies.at(-1);
350
- return reply === undefined
351
- ? null
352
- : {
353
- pending: finalized.pending,
354
- reply,
355
- replies,
356
- agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
357
- agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
358
- ...claimMarker(finalized.response),
359
- };
360
- }
361
- /** Persist and surface one answer without letting the two representations drift. */
362
- function reportAnswer(ctx, notes, answered, late) {
363
- ctx.log?.info('hook.answer', {
364
- answered: true,
365
- stage: 'queued',
366
- ...(late ? { late: true } : {}),
367
- request_id: answered.pending.request_id,
368
- device: answered.reply.device_name,
369
- text_chars: answered.reply.text.length,
370
- });
371
- notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
372
- }
373
- /**
374
- * One bounded recovery poll across every delivered question, concurrently —
375
- * N questions must not multiply the hook's latency budget by N.
376
- */
377
- async function pollPendingReplies(ctx, live, timeoutSeconds) {
378
- const polls = await Promise.all(live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)));
379
- const answered = [];
380
- let transientTrouble = false;
381
- const permanentFailures = new Map();
382
- for (const [index, poll] of polls.entries()) {
383
- if (poll.reply !== null) {
384
- answered.push({ pending: live[index], reply: poll.reply, replies: poll.replies });
385
- }
386
- if (poll.degraded)
387
- transientTrouble = true;
388
- if (poll.permanentFailure !== null) {
389
- permanentFailures.set(live[index].request_id, poll.permanentFailure);
390
- }
391
- }
392
- return { answered, transientTrouble, permanentFailures };
393
- }
394
- /**
395
- * The frozen draft itself will never be accepted. Replaying it forever hides a
396
- * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
397
- * Auth, conflicts, and timeouts are not this class.
398
- */
399
- function isTerminalDraftRejection(err) {
400
- return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
401
- }
402
- // ---------------------------------------------------------------------------
403
- // UserPromptSubmit — the user is at the keyboard
404
- // ---------------------------------------------------------------------------
405
- /**
406
- * Records presence. Retires a live question only when this prompt plausibly
407
- * answers it. Typing in the terminal is not by itself a resolution: the User
408
- * may be answering a different question on a device.
409
- */
410
- export async function handleUserPromptSubmit(ctx, envelope) {
411
- const sessionId = envelope.session_id;
412
- if (!sessionId)
413
- return { notes: [] };
414
- if (ctx.harness === 'codex') {
415
- const presented = observeQueuedMessagePrompt(sessionId, ctx.env, envelope.prompt);
416
- if (presented.length > 0)
417
- ctx.log?.info('delivery.handoff', {
418
- route: 'session-queue', stage: 'prompt-observed', message_ids: presented,
419
- });
420
- }
421
- // Owed Session Message acknowledgements come before any resumed work: a
422
- // session resumed after SessionEnd has no Stop to remind it first.
423
- const reminder = { text: await messageAcknowledgementReminder(ctx, sessionId), used: false };
424
- const outcome = await answerPrompt(ctx, envelope, sessionId, reminder);
425
- if (reminder.text === null || reminder.used || outcome.stdout !== undefined || sessionHasEnded(sessionId, ctx.env)) {
426
- return outcome;
427
- }
428
- const stdout = userPromptContextOutput(ctx.harness, reminder.text);
429
- if (stdout === undefined)
430
- return outcome;
431
- outcome.notes.push('an owed Session Message acknowledgement was added to the user\'s new turn');
432
- return {
433
- ...outcome,
434
- stdout,
435
- decided: false,
436
- log: { ...outcome.log, stage: outcome.log?.['stage'] ?? 'acknowledgement-reminded' },
437
- };
438
- }
439
- /** Context for the new turn, carrying the owed-acknowledgement reminder once. */
440
- function withReminder(reminder, context) {
441
- if (reminder.text === null)
442
- return context;
443
- reminder.used = true;
444
- return `${context}\n\n${reminder.text}`;
445
- }
446
- /**
447
- * The instruction for Session Message acknowledgements still owed after asking
448
- * the service, or null when none is.
449
- */
450
- async function messageAcknowledgementReminder(ctx, sessionId) {
451
- const generation = ctx.harness === 'openclaw' ? readSessionIncarnation(sessionId, ctx.env)?.openclaw_generation : undefined;
452
- const owed = presentedMessageAcknowledgements(readSessionState(sessionId, ctx.env))
453
- .filter((entry) => ctx.harness !== 'openclaw' || (generation !== undefined && entry.openclaw_generation === generation));
454
- if (owed.length === 0)
455
- return null;
456
- const still = await reconcileAcknowledgementObligations(ctx, sessionId, owed);
457
- return still.length === 0 ? null : acknowledgementBlockContext(still);
458
- }
459
- async function answerPrompt(ctx, envelope, sessionId, reminder) {
460
- const notes = [];
461
- recoverQueuedAnswers(sessionId, ctx.env);
462
- let state = readSessionState(sessionId, ctx.env);
463
- if (state.accepted !== undefined || (state.delivered_answers?.length ?? 0) > 0) {
464
- // A successful command can clear local debt before the next Stop, and a
465
- // interrupted command can leave debt after the service recorded its
466
- // acknowledgement. Reconcile both before replaying recovery context.
467
- await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
468
- if (sessionHasEnded(sessionId, ctx.env)) {
469
- notes.push('the Agent Session ended before answer delivery; stopping this observer');
470
- return { notes };
471
- }
472
- state = readSessionState(sessionId, ctx.env);
473
- if (state.accepted !== undefined &&
474
- acceptedAnswersAwaitingAcknowledgement(state.accepted, state).length === 0) {
475
- settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
476
- state = readSessionState(sessionId, ctx.env);
477
- }
478
- }
479
- if (state.accepted !== undefined) {
480
- // A journaled write of this batch already began (claimed, or recorded
481
- // after an unclaimed hand-off): adding it to this turn would hand it over
482
- // twice. It settles instead; its acknowledgement stays owed.
483
- const written = answersAlreadyWritten(sessionId, ctx.env, state.accepted.answers.flatMap(({ pending }) => (pending.request_id === undefined ? [] : [pending.request_id])));
484
- if (written.length > 0) {
485
- settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
486
- ctx.log?.info('hook.answer', {
487
- answered: true,
488
- stage: 'replay-suppressed',
489
- route: 'user-prompt-submit',
490
- journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
491
- });
492
- notes.push('an earlier hand-off of the journaled answer already began its write; not adding it to this turn');
493
- state = readSessionState(sessionId, ctx.env);
494
- }
495
- }
496
- if (state.accepted !== undefined) {
497
- const answers = [
498
- ...(state.delivered_answers ?? []),
499
- ...acceptedAnswersAwaitingAcknowledgement(state.accepted, state),
500
- ];
501
- updateSessionState(sessionId, ctx.env, (current) => ({
502
- ...current,
503
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
504
- last_prompt_at: ctx.now(),
505
- }));
506
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
507
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
508
- }
509
- const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, answers, state.accepted.remaining)));
510
- if (stdout !== undefined) {
511
- notes.push('the journaled device answer was added to the user\'s new turn');
512
- const selected = unclaimedSelectedAnswers(sessionId, ctx.env, answers);
513
- return {
514
- stdout,
515
- decided: false,
516
- notes,
517
- ...(selected.length === 0
518
- ? {}
519
- : { afterOutput: () => reportHandedOffAfterTheFact(ctx, sessionId, selected) }),
520
- log: {
521
- stage: 'context-added',
522
- route: 'user-prompt-submit',
523
- request_ids: answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]),
524
- },
525
- };
526
- }
527
- notes.push('a device answer is safely journaled; the next Stop will deliver it');
528
- return { notes };
529
- }
530
- const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
531
- let lateAnswers = [];
532
- if (live.length > 0) {
533
- const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
534
- const finalized = await finalizePendings(ctx, sessionId, answered.map((entry) => entry.pending));
535
- lateAnswers = answered.map((entry) => {
536
- const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
537
- const replies = response?.replies.length ? response.replies : entry.replies;
538
- if (response === null) {
539
- notes.push(`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`);
540
- }
541
- return {
542
- ...entry,
543
- replies,
544
- reply: replies.at(-1),
545
- agent_acknowledgement_required: response?.agent_acknowledgement_required ??
546
- entry.agent_acknowledgement_required,
547
- agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
548
- entry.agent_acknowledgement_text_required,
549
- };
550
- });
551
- if (answered.length > 0) {
552
- stageAcceptedAnswers(ctx, sessionId, lateAnswers, 0);
553
- for (const answer of lateAnswers)
554
- reportAnswer(ctx, notes, answer, true);
555
- }
556
- if (transientTrouble) {
557
- notes.push('could not check every pending question for a late answer before the prompt');
558
- }
559
- const permanentFailure = permanentReplyFailureNote(permanentFailures);
560
- if (permanentFailure !== null)
561
- notes.push(permanentFailure);
562
- }
563
- const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state)).filter(entry => mayRetireFromPrompt(state, entry));
564
- for (const entry of matched) {
565
- ctx.log?.info('hook.retirement', {
566
- request_id: entry.request_id,
567
- question_id: entry.question_id,
568
- reason: 'prompt-matched',
569
- attempted: entry.request_id !== undefined,
570
- proven: false,
571
- });
572
- }
573
- // Park before dropping `pending`. If the process dies between these writes,
574
- // the next hook sees both copies and dedupes them; the old order could die in
575
- // the gap after erasing the only request/collapse/device identifiers.
576
- const updated = updateSessionState(sessionId, ctx.env, (current) => {
577
- const retiring = [...(current.retiring ?? [])];
578
- const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current)).filter(entry => mayRetireFromPrompt(current, entry));
579
- const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
580
- for (const entry of matchedNow.filter(questionSubmissionAttempted)) {
581
- const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
582
- if (retirement !== null &&
583
- !retiring.some((parked) => parked.request_id === retirement.request_id)) {
584
- retiring.push(retirement);
585
- }
586
- }
587
- const unasked = unmatched.filter((entry) => entry.request_id === undefined);
588
- const stillLive = unmatched.filter((entry) => entry.request_id !== undefined);
589
- const remaining = [...stillLive, ...unasked];
590
- // Start from the whole document so fields introduced by a newer CLI remain
591
- // attached while this older writer applies the prompt transition it knows.
592
- const next = {
593
- ...current,
594
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
595
- last_prompt_at: ctx.now(),
596
- };
597
- if (remaining.length > 0)
598
- next.pending = remaining;
599
- else
600
- delete next.pending;
601
- if (retiring.length > 0)
602
- next.retiring = retiring;
603
- else
604
- delete next.retiring;
605
- // The user typed, so whatever chain of answer-driven continuations was
606
- // running, a human has taken the turn and the consecutive count starts
607
- // over. Only a real prompt reaches here: the journal branch above returns
608
- // first, and a wake route's injected message always arrives while its own
609
- // answer is still journaled, so it can never pass for the user's presence.
610
- if (current.continuation !== undefined) {
611
- next.continuation = { ...current.continuation, count: 0 };
612
- }
613
- return matchedNow.reduce((remembered, entry) => rememberQuestionState(remembered, entry, questionSubmissionAttempted(entry) ? 'retired' : 'withdrawn'), next);
614
- });
615
- // The bridge that lets a plain `notifai ask` find the hook's canonical
616
- // session: an agent shell command gets no hook payload, and not every
617
- // harness exports an id in the same shape.
618
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
619
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
620
- }
621
- if (lateAnswers.length > 0) {
622
- // UserPromptSubmit can add context to the turn already beginning. Keep the
623
- // accepted-answer journal until the agent acknowledges: if this process or
624
- // host dies around stdout, a later prompt or Stop safely replays it.
625
- const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, [...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
626
- if (stdout !== undefined)
627
- notes.push('the late device answer was added to the user\'s new turn');
628
- else
629
- notes.push('the late device answer will continue the agent at this turn’s Stop');
630
- return {
631
- notes,
632
- ...(stdout === undefined
633
- ? {}
634
- : {
635
- stdout,
636
- decided: false,
637
- log: {
638
- stage: 'context-added',
639
- route: 'user-prompt-submit',
640
- request_ids: lateAnswers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]),
641
- },
642
- }),
643
- settlementRequired: needsQuestionSettlement(updated, ctx.harness),
644
- };
645
- }
646
- const retired = await drainRetirements(ctx, sessionId, ctx.env);
647
- const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
648
- await sweepDeliveryJournalsFor(ctx);
649
- const swept = [...retired, ...orphaned];
650
- if (swept.length > 0) {
651
- notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
652
- }
653
- const delivered = readSessionState(sessionId, ctx.env).delivered_answers ?? [];
654
- const stdout = delivered.length > 0
655
- ? userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, delivered, pendingList(updated).length)))
656
- : undefined;
657
- return {
658
- notes,
659
- ...(stdout === undefined ? {} : {
660
- stdout,
661
- log: { stage: 'context-added', route: 'user-prompt-submit', request_ids: summarizeRequestIds(delivered.map((entry) => entry.pending)).ids },
662
- }),
663
- settlementRequired: needsQuestionSettlement(updated, ctx.harness),
664
- };
665
- }
666
- /** Legacy prompt/Stop context routes bypass the common drain. Select ordinary
667
- * presentation durably before returning any answer bytes to those writers.
668
- */
669
- function prepareOrdinaryAnswersContext(ctx, sessionId, answers, remaining) {
670
- if (ctx.harness === 'codex') {
671
- const ids = new Set(answers.flatMap(answer => answer.pending.question_id === undefined ? [] : [answer.pending.question_id]));
672
- updateSessionState(sessionId, ctx.env, state => markCodexOrdinaryPresentation(state, ids));
673
- }
674
- return answersContext(answers, remaining);
675
- }
676
- function needsQuestionSettlement(state, harness) {
677
- return pendingList(state).some((entry) => entry.request_id === undefined || harness === 'codex');
678
- }
679
- /** Absence of debt proves consumption only for an explicitly classified answer. */
680
- function acceptedAnswersAwaitingAcknowledgement(accepted, state) {
681
- return accepted.answers.filter(({ pending, agent_acknowledgement_required }) => agent_acknowledgement_required !== true ||
682
- pending.request_id === undefined ||
683
- (state.acknowledgement_due ?? []).some((entry) => entry.request_id === pending.request_id));
684
- }
685
- // ---------------------------------------------------------------------------
686
- // Answer ownership — observe registered questions through a harness delivery route
687
- // ---------------------------------------------------------------------------
688
- /**
689
- * Only engages for a question the agent explicitly registered with
690
- * `notifai ask`. Guessing from the last assistant message was the alternative
691
- * and it is not worth it: a false positive here hijacks the terminal.
692
- */
693
- export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute(), recordStop = true) {
694
- const sessionId = envelope.session_id;
695
- if (!sessionId) {
696
- gate(ctx, 'held', 'no-session', { stage: 'queued' });
697
- return { notes: [] };
698
- }
699
- return runEscalationWaiter(ctx, {
700
- sessionId,
701
- envelope,
702
- route,
703
- processDeadlineAt,
704
- recordStop,
705
- });
706
- }
707
- /**
708
- * Acquire the per-session owner, waiting only when this Stop carries a
709
- * newly registered question. The live owner sees that state on its next reply
710
- * poll and yields; without this handoff the new question could sit unpushed for
711
- * the older question's complete answer window.
712
- */
713
- async function acquireQuestionOwner(ctx, sessionId, hardDeadlineAt) {
714
- if (sessionHasEnded(sessionId, ctx.env))
715
- return false;
716
- if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
717
- return true;
718
- while (ctx.now() < hardDeadlineAt) {
719
- const handoff = claimHandoffState(sessionId, ctx.env);
720
- if (!handoff.hasNewQuestion)
721
- return false;
722
- const holderReleaseDeadlineAt = Math.min(hardDeadlineAt, handoff.ownerDeadlineAt === undefined
723
- ? hardDeadlineAt
724
- : handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000);
725
- if (ctx.now() >= holderReleaseDeadlineAt)
726
- return false;
727
- await ctx.sleep(Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()));
728
- if (sessionHasEnded(sessionId, ctx.env))
729
- return false;
730
- if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
731
- return true;
732
- }
733
- return false;
734
- }
735
- /**
736
- * Own one registered question pipeline from its terminal-first timer through
737
- * its final delivery or retirement. Hosts differ only in the injected route.
738
- */
739
- export async function runEscalationWaiter(ctx, options) {
740
- const notes = [];
741
- const { sessionId, envelope } = options;
742
- // Detached observers have no harness stdout consumer for Stop reminders.
743
- // They must neither spend reminder attempts nor block fresh answer collection.
744
- const canRemind = options.recordStop !== false;
745
- const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000;
746
- if (sessionHasEnded(sessionId, ctx.env)) {
747
- notes.push('the Agent Session already ended; no answer observer was started');
748
- return { notes };
749
- }
750
- // This event marker is diagnostic evidence in its own right. Record it
751
- // before every early return so doctor can distinguish a working Stop route
752
- // from the prompt-only state that previously looked healthy.
753
- if (options.recordStop !== false) {
754
- updateSessionState(sessionId, ctx.env, (current) => ({
755
- ...current,
756
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
757
- last_stop_at: ctx.now(),
758
- }));
759
- }
760
- // The waiter, not its host, owns this lease. It spans grace, submission,
761
- // polling, close fencing, route delivery, and every retirement path.
762
- // Real clock, deliberately, not `ctx.now` — the claim answers whether
763
- // another process is alive right now, which an injected clock cannot know.
764
- if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
765
- gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' });
766
- notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner');
767
- return { notes };
768
- }
769
- try {
770
- recoverQueuedAnswers(sessionId, ctx.env);
771
- let state = readSessionState(sessionId, ctx.env);
772
- if (state.accepted !== undefined) {
773
- const accepted = state.accepted;
774
- if (canRemind && envelope.stop_hook_active === true && pendingList(state).length > 0) {
775
- const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
776
- if (due.length > 0) {
777
- gate(ctx, 'held', 'acknowledgement-required', {
778
- request_ids: due.map((entry) => entry.request_id),
779
- });
780
- return {
781
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
782
- notes,
783
- log: {
784
- stage: 'acknowledgement-required',
785
- request_ids: due.map((entry) => entry.request_id),
786
- },
787
- };
788
- }
789
- state = readSessionState(sessionId, ctx.env);
790
- }
791
- // Acknowledgement is per route, because routes end in different places.
792
- //
793
- // A blocking continuation ends at this process's stdout, which the
794
- // harness reads only after the process exits: the recursive Stop's
795
- // `stop_hook_active` is the acknowledgement, and persisting a separate
796
- // "offered" bit before stdout would recreate the crash gap this journal
797
- // exists to close. An out-of-band route ends at a write this process made
798
- // itself and recorded here. A socket write is not proof the model acted
799
- // on the answer — delivery is not consumption — but nothing later can
800
- // prove more, and an answer redelivered without end is strictly worse
801
- // than one settled on delivery. So a recorded delivery settles.
802
- const acknowledgementProvesDelivery = accepted.answers.length > 0 &&
803
- acceptedAnswersAwaitingAcknowledgement(accepted, state).length === 0;
804
- const deliveryProven = accepted.delivered_at !== undefined ||
805
- envelope.stop_hook_active === true ||
806
- acknowledgementProvesDelivery;
807
- if (!deliveryProven) {
808
- for (const answer of accepted.answers)
809
- reportAnswer(ctx, notes, answer, true);
810
- return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes, envelope.cwd);
811
- }
812
- settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd);
813
- state = readSessionState(sessionId, ctx.env);
814
- const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
815
- const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
816
- const held = canRemind ? holdForAcknowledgement(ctx, sessionId, due, notes) : null;
817
- if (held !== null)
818
- return held;
819
- state = readSessionState(sessionId, ctx.env);
820
- }
821
- if (state.accepted === undefined && owedAcknowledgements(state).length > 0 &&
822
- canRemind) {
823
- const due = await reconcileAcknowledgementObligations(ctx, sessionId, owedAcknowledgements(state));
824
- const held = canRemind ? holdForAcknowledgement(ctx, sessionId, due, notes) : null;
825
- if (held !== null)
826
- return held;
827
- resetAcknowledgementBlocks(sessionId, ctx.env);
828
- state = readSessionState(sessionId, ctx.env);
829
- }
830
- const pending = pendingList(state);
831
- if (pending.length === 0) {
832
- const swept = [
833
- ...(await drainRetirements(ctx, sessionId, ctx.env)),
834
- ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
835
- ];
836
- await sweepDeliveryJournalsFor(ctx);
837
- if (swept.length > 0) {
838
- notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
839
- }
840
- gate(ctx, 'held', 'no-question', { stage: 'queued' });
841
- return { notes };
842
- }
843
- return await handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardDeadlineAt, options.route, options.recordStop !== false && ctx.harness === 'codex' && options.route.kind === 'session-queue');
844
- }
845
- finally {
846
- releaseQuestionPush(sessionId, ctx.env);
847
- }
848
- }
849
- /**
850
- * The blocking Stop hook's own last meter: the answer becomes this process's
851
- * stdout, and the harness admits it as a new user turn. Exported because a
852
- * host adapter that has other routes still falls back to this one.
853
- */
854
- export function hookContinuationRoute() {
855
- return {
856
- kind: 'hook-continuation',
857
- deliver: async (event) => ({
858
- stdout: stopAnswerOutput(event.context),
859
- commitStdout: event.commitDelivery,
860
- notes: [],
861
- // The harness reads this stdout after the process exits, so only the
862
- // successor Stop can acknowledge it.
863
- acknowledgement: 'stdout',
864
- }),
865
- };
866
- }
867
- /**
868
- * Hand one accepted answer to a route and record what the attempt proved.
869
- *
870
- * Every delivery on every route passes through here, which is what makes the
871
- * cap below a backstop rather than a suggestion. A guard only one route
872
- * consults is not a backstop: that is how a single answer once reached a
873
- * session 250 times.
874
- */
875
- async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cwd) {
876
- if (route.kind === 'session-queue' || route.defer !== undefined) {
877
- accepted = classifyJournaledAcknowledgements(sessionId, ctx.env) ?? accepted;
878
- const ids = new Set(accepted.answers.map((answer) => answer.pending.request_id));
879
- await reconcileAcknowledgementObligations(ctx, sessionId, (readSessionState(sessionId, ctx.env).acknowledgement_due ?? []).filter((entry) => ids.has(entry.request_id)));
880
- if (sessionHasEnded(sessionId, ctx.env)) {
881
- notes.push('the Agent Session ended before answer delivery; stopping this observer');
882
- return { notes };
883
- }
884
- const unresolved = acceptedAnswersAwaitingAcknowledgement(accepted, readSessionState(sessionId, ctx.env));
885
- if (unresolved.length === 0) {
886
- settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
887
- return {
888
- notes,
889
- log: { stage: 'acknowledgement-reconciled', request_ids: [...ids] },
890
- settlementRequired: pendingList(readSessionState(sessionId, ctx.env)).length > 0,
891
- };
892
- }
893
- accepted = { ...accepted, answers: unresolved };
894
- // Keep the complete fenced batch in storage until queue completion, so
895
- // acknowledged members still retain any unconfirmed retirement work.
896
- }
897
- const { answers: answered, remaining } = accepted;
898
- const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
899
- if (sessionHasEnded(sessionId, ctx.env)) {
900
- notes.push('the Agent Session ended before answer delivery; stopping this observer');
901
- return { notes };
902
- }
903
- const replayed = await suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd);
904
- if (replayed !== null)
905
- return replayed;
906
- if (route.defer !== undefined) {
907
- await route.defer(accepted);
908
- return {
909
- notes,
910
- log: { stage: 'input-staged', route: route.kind, answers: accepted.answers.length },
911
- settlementRequired: pendingList(readSessionState(sessionId, ctx.env)).length > 0,
912
- };
913
- }
914
- const held = accepted.held_deliveries ?? 0;
915
- if (held >= MAX_HELD_DELIVERIES) {
916
- gate(ctx, 'held', 'delivery-limit', {
917
- route: route.kind,
918
- held,
919
- limit: MAX_HELD_DELIVERIES,
920
- request_ids: requestIds,
921
- });
922
- settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
923
- notes.push(`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`);
924
- return { notes };
925
- }
926
- const attempt = (accepted.delivery_attempts ?? 0) + 1;
927
- if (attempt > MAX_CONTINUATION_COUNT) {
928
- gate(ctx, 'held', 'delivery-limit', {
929
- route: route.kind,
930
- attempts: accepted.delivery_attempts ?? 0,
931
- limit: MAX_CONTINUATION_COUNT,
932
- request_ids: requestIds,
933
- });
934
- // Settle rather than journal for ever. The answer has been offered as often
935
- // as any working route could need, it is already in the notes of each of
936
- // those turns, and the questions it answered still deserve retirement.
937
- settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
938
- notes.push(`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`);
939
- return { notes };
940
- }
941
- // Count the attempt before making it: an attempt that dies mid-delivery still
942
- // happened, and a counter written afterwards could be evaded by crashing.
943
- amendAcceptedAnswers(ctx, sessionId, (current) => ({
944
- ...current,
945
- delivery_attempts: attempt,
946
- }));
947
- // Linearize delivery after the journal write. If SessionEnd acquired the
948
- // shared state lock in between, its durable marker wins and no wake starts.
949
- if (sessionHasEnded(sessionId, ctx.env)) {
950
- notes.push('the Agent Session ended before answer delivery; stopping this observer');
951
- return { notes };
952
- }
953
- ctx.log?.info('hook.gate', {
954
- verdict: 'proceeding',
955
- reason: 'answered',
956
- stage: 'routed',
957
- route: route.kind,
958
- answers: answered.length,
959
- request_ids: requestIds,
960
- journal_recorded_at: accepted.recorded_at,
961
- delivery_attempt: attempt,
962
- });
963
- let deliveryCommitted = false;
964
- const commitDelivery = () => {
965
- if (deliveryCommitted)
966
- return true;
967
- const file = sessionStatePath(sessionId, ctx.env);
968
- return withFileLock(`${file}.lock`, () => {
969
- if (sessionHasEnded(sessionId, ctx.env))
970
- return false;
971
- let current = readSessionState(sessionId, ctx.env);
972
- if (current.accepted === undefined)
973
- return false;
974
- const acceptedBeforeCommit = current.accepted;
975
- deliveryCommitted = true;
976
- if (ctx.harness === 'codex')
977
- current = markCodexOrdinaryPresentation(current, new Set(answered.flatMap(answer => answer.pending.question_id === undefined ? [] : [answer.pending.question_id])));
978
- writeSessionStateUnlocked(file, sessionId, {
979
- ...current,
980
- accepted: { ...acceptedBeforeCommit, delivery_committed_at: ctx.now() },
981
- });
982
- return true;
983
- });
984
- };
985
- // Answers a `deliver` close selected are claimed first, under this session's
986
- // delivery lock, so an Answer Edit can only follow the write. A claim that
987
- // cannot be had never withholds the answer: it is written unclaimed, as
988
- // released CLIs write it, and its edits simply wait.
989
- const handOff = await claimAnswerHandOff(ctx, sessionId, route, answered);
990
- let delivered;
991
- try {
992
- delivered = await route.deliver({
993
- context: answersContext(answered, remaining),
994
- answers: answered.length,
995
- remaining,
996
- request_ids: requestIds,
997
- journal_recorded_at: accepted.recorded_at,
998
- commitDelivery: handOff === null
999
- ? commitDelivery
1000
- : (writer) => handOff.begin(commitDelivery, { subprocess: writer === 'subprocess' }),
1001
- ...(handOff === null || handOff.claimed.length === 0
1002
- ? {}
1003
- : {
1004
- writeGuard: { writable: () => handOff.writable(), remainingMs: () => handOff.remainingMs() },
1005
- writerGroup: (pgid) => handOff.recordGroup(pgid),
1006
- }),
1007
- });
1008
- }
1009
- catch (err) {
1010
- await handOff?.finish('failed');
1011
- throw err;
1012
- }
1013
- await handOff?.finish(delivered.acknowledgement === 'delivered'
1014
- ? 'written'
1015
- : delivered.log?.['reason'] === 'write-unconfirmed'
1016
- ? 'failed'
1017
- : delivered.log?.['reason'] === WRITE_ABORTED_REASON
1018
- ? 'aborted'
1019
- : 'not-written');
1020
- if (delivered.acknowledgement === 'delivered' && deliveryCommitted) {
1021
- await reportUnclaimedHandOff(ctx, sessionId, answered, handOff);
1022
- }
1023
- if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
1024
- delivered = {
1025
- notes: [
1026
- ...(delivered.notes ?? []),
1027
- `the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
1028
- ],
1029
- log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
1030
- acknowledgement: 'held',
1031
- };
1032
- }
1033
- const deliveredRoute = typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind;
1034
- const deliveredStage = typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered';
1035
- if (delivered.acknowledgement === 'delivered') {
1036
- finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute);
1037
- }
1038
- else if (delivered.acknowledgement === 'held') {
1039
- // Give the delivery attempt back and count the hold instead. The attempt
1040
- // was counted before the call because one that dies mid-delivery still
1041
- // happened and a counter written afterwards could be evaded by crashing —
1042
- // but a route that *returns* `held` proves both that this process survived
1043
- // and that nothing was handed over.
1044
- amendAcceptedAnswers(ctx, sessionId, (current) => ({
1045
- ...current,
1046
- delivery_attempts: accepted.delivery_attempts ?? 0,
1047
- held_deliveries: (current.held_deliveries ?? 0) + 1,
1048
- }));
1049
- }
1050
- ctx.log?.info('hook.answer', {
1051
- answered: true,
1052
- stage: deliveredStage,
1053
- route: deliveredRoute,
1054
- acknowledgement: delivered.acknowledgement,
1055
- delivery_attempt: attempt,
1056
- request_ids: requestIds,
1057
- answers: answered.length,
1058
- journal_recorded_at: accepted.recorded_at,
1059
- ...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
1060
- });
1061
- const outcome = { notes: [...notes, ...(delivered.notes ?? [])] };
1062
- if (delivered.stdout !== undefined)
1063
- outcome.stdout = delivered.stdout;
1064
- if (delivered.commitStdout !== undefined)
1065
- outcome.commitStdout = delivered.commitStdout;
1066
- if (delivered.log !== undefined)
1067
- outcome.log = delivered.log;
1068
- if (route.kind === 'session-queue' && delivered.acknowledgement === 'delivered') {
1069
- outcome.settlementRequired = pendingList(readSessionState(sessionId, ctx.env)).length > 0;
1070
- }
1071
- return outcome;
1072
- }
1073
- /**
1074
- * A restart must never write an answer twice. When this session's delivery
1075
- * journal shows that an earlier hand-off of any answer in the batch already
1076
- * began its write — completed (`written`) or possibly (`writing`, `failed`) —
1077
- * the batch settles without another write. Journal recovery reports the
1078
- * earlier attempt's outcome; an unconfirmed one is never retried.
1079
- */
1080
- async function suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd) {
1081
- const requestIds = accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]);
1082
- const written = answersAlreadyWritten(sessionId, ctx.env, requestIds);
1083
- if (written.length === 0)
1084
- return null;
1085
- if (ctx.answerClaims !== undefined) {
1086
- try {
1087
- await recoverDeliveryJournal(answerSequencer(ctx, sessionId, ctx.answerClaims));
1088
- }
1089
- catch {
1090
- // Reporting is recovery's to retry; suppression does not depend on it.
1091
- }
1092
- }
1093
- settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
1094
- ctx.log?.info('hook.answer', {
1095
- answered: true,
1096
- stage: 'replay-suppressed',
1097
- request_ids: requestIds,
1098
- journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
1099
- });
1100
- notes.push('an earlier hand-off of this answer already began its write; not writing it again');
1101
- return { notes, log: { stage: 'replay-suppressed', request_ids: requestIds } };
1102
- }
1103
- /**
1104
- * The Answer Edit release rule needs the fenced answer's outcome. A selected
1105
- * answer written without a claim (the lease moved, the lock stayed busy, the
1106
- * claim was refused) is recorded after the fact as handed off, so its edits
1107
- * follow it instead of waiting on an attempt that never existed.
1108
- */
1109
- async function reportUnclaimedHandOff(ctx, sessionId, answered, handOff) {
1110
- const claimed = new Set((handOff?.claimed ?? []).flatMap((attempt) => attempt.subject.type === 'answer' ? [attempt.subject.request_id] : []));
1111
- const unclaimed = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined && !claimed.has(pending.request_id)
1112
- ? [pending.request_id]
1113
- : []);
1114
- await reportHandedOffAfterTheFact(ctx, sessionId, unclaimed);
1115
- }
1116
- /**
1117
- * Journal, then record, selected answers already written without a claim. A
1118
- * record that fails stays in this session's delivery journal, so the next
1119
- * hand-off here or the machine-wide sweep records it later.
1120
- */
1121
- export async function reportHandedOffAfterTheFact(ctx, sessionId, requestIds) {
1122
- if (requestIds.length === 0)
1123
- return;
1124
- const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
1125
- if (writer === null)
1126
- return;
1127
- await recordUnclaimedHandOffs({
1128
- sessionId,
1129
- env: ctx.env,
1130
- client: ctx.client,
1131
- monotonic: ctx.answerClaims?.monotonic ?? (() => performance.now()),
1132
- wall: ctx.now,
1133
- sleep: ctx.sleep,
1134
- writer,
1135
- ...(ctx.log === undefined ? {} : { log: ctx.log }),
1136
- }, requestIds);
1137
- }
1138
- function answerSequencer(ctx, sessionId, claims) {
1139
- return {
1140
- sessionId,
1141
- env: ctx.env,
1142
- client: ctx.client,
1143
- monotonic: claims.monotonic,
1144
- wall: ctx.now,
1145
- sleep: ctx.sleep,
1146
- writer: claims.writer,
1147
- ...(ctx.log === undefined ? {} : { log: ctx.log }),
1148
- };
1149
- }
1150
- /**
1151
- * Selected answers this prompt hands off in its context without a claim; the
1152
- * ones a claimed write already covered are left to that attempt.
1153
- */
1154
- function unclaimedSelectedAnswers(sessionId, env, answers) {
1155
- const selected = answers.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined ? [pending.request_id] : []);
1156
- const covered = new Set(answersAlreadyWritten(sessionId, env, selected).map((entry) => entry.requestId));
1157
- return selected.filter((requestId) => !covered.has(requestId));
1158
- }
1159
- /** The machine-wide journal sweep, from a hook that already holds a client. */
1160
- async function sweepDeliveryJournalsFor(ctx) {
1161
- const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
1162
- if (writer === null)
1163
- return;
1164
- try {
1165
- await sweepDeliveryJournals({
1166
- env: ctx.env,
1167
- client: ctx.client,
1168
- writer,
1169
- now: ctx.now(),
1170
- ...(ctx.log === undefined ? {} : { log: ctx.log }),
1171
- });
1172
- }
1173
- catch {
1174
- // Housekeeping never fails a hook; the next sweep retries.
1175
- }
1176
- }
1177
- /** How long a waiter waits for the attendant to re-acquire a moved lease. */
1178
- const ANSWER_CLAIM_RETRY_MS = 2_000;
1179
- const ANSWER_CLAIM_ROUNDS = 3;
1180
- /**
1181
- * Claim every selected answer of this batch for one in-place write, or null
1182
- * when nothing may be claimed: no attendant lease, a route that does not write
1183
- * in place, or a delivery lock another writer kept.
1184
- */
1185
- async function claimAnswerHandOff(ctx, sessionId, route, answered) {
1186
- const claims = ctx.answerClaims;
1187
- const subjects = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined
1188
- ? [{ type: 'answer', request_id: pending.request_id }]
1189
- : []);
1190
- if (claims === undefined || subjects.length === 0 || !claimableRoute(route))
1191
- return null;
1192
- const deps = answerSequencer(ctx, sessionId, claims);
1193
- for (let round = 1;; round += 1) {
1194
- const lease = claims.lease();
1195
- const handOff = lease === null ? null : await beginHandOff(deps, { lease, subjects });
1196
- if (handOff === null) {
1197
- ctx.log?.info('delivery.claimed', {
1198
- subject: 'answer',
1199
- request_ids: subjects.map((subject) => subject.request_id),
1200
- claimed: false,
1201
- reason: lease === null ? 'no-attendant-lease' : 'delivery-lock-busy',
1202
- });
1203
- if (lease === null && round < ANSWER_CLAIM_ROUNDS) {
1204
- await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
1205
- continue;
1206
- }
1207
- return null;
1208
- }
1209
- // A lease that moved between the close and this claim is re-read once the
1210
- // attendant has had a moment to re-acquire; any other refusal is final.
1211
- const fenced = handOff.claimed.length === 0 &&
1212
- handOff.refused.every((refusal) => refusal.reason === 'generation_fenced');
1213
- if (!fenced || round >= ANSWER_CLAIM_ROUNDS) {
1214
- if (ctx.harness === 'hermes' && handOff.claimed.length !== subjects.length) {
1215
- await handOff.finish('not-written');
1216
- return null;
1217
- }
1218
- return handOff;
1219
- }
1220
- await handOff.finish('not-written');
1221
- await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
1222
- }
1223
- }
1224
- /** True while this Stop owner is still allowed a direct-wake wait. */
1225
- function ownerLeaseActive(entry, now) {
1226
- return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
1227
- }
1228
- /** The complete per-session Stop decision, after this process owns the claim. */
1229
- async function handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
1230
- const live = pending.filter((entry) => entry.request_id !== undefined);
1231
- const unasked = pending.filter((entry) => entry.request_id === undefined);
1232
- if (ctx.harness !== undefined &&
1233
- HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported') {
1234
- for (const entry of live)
1235
- await closeQuietly(ctx, entry.request_id);
1236
- await retirePendings(ctx, envelope, sessionId, pending, 'expired');
1237
- // Not `no-question`: there was a question, and this says so. Reading the
1238
- // log to find out why an ask never travelled is the whole reason these
1239
- // reasons exist, and the wrong one sent that reader looking for a question
1240
- // that was right there.
1241
- gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness });
1242
- notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
1243
- return { notes };
1244
- }
1245
- if (live.length > 0) {
1246
- const { answered, permanentFailures } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS);
1247
- const permanentFailure = permanentReplyFailureNote(permanentFailures);
1248
- if (permanentFailure !== null)
1249
- notes.push(permanentFailure);
1250
- const permanentlyRejected = live.filter((entry) => permanentFailures.has(entry.request_id));
1251
- await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id)));
1252
- if (permanentlyRejected.length > 0) {
1253
- await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
1254
- }
1255
- if (answered.length > 0) {
1256
- const finalized = await finalizePendings(ctx, sessionId, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
1257
- const authoritative = answered.map((entry) => {
1258
- const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
1259
- const replies = response?.replies.length ? response.replies : entry.replies;
1260
- if (response === null) {
1261
- notes.push(`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`);
1262
- }
1263
- return {
1264
- ...entry,
1265
- replies,
1266
- reply: replies.at(-1),
1267
- agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1268
- entry.agent_acknowledgement_required,
1269
- agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
1270
- entry.agent_acknowledgement_text_required,
1271
- ...claimMarker(response),
1272
- };
1273
- });
1274
- const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
1275
- for (const answer of authoritative)
1276
- reportAnswer(ctx, notes, answer, true);
1277
- // Anything still unasked rides the next Stop: the agent is being resumed
1278
- // with answers right now, and may not even need the rest afterwards.
1279
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1280
- }
1281
- const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1282
- // A spent owner has already reached the end of the answer window it owned.
1283
- // Passing those questions back into `escalate` starts a fresh ceiling and
1284
- // can monopolize the per-session claim after expiry. Independent questions
1285
- // whose lease is still running keep their successor wait.
1286
- const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
1287
- if (unasked.length === 0) {
1288
- if (recoverableLive.length === 0)
1289
- return { notes };
1290
- if (stillOwned.length === 0) {
1291
- const requestIdSummary = summarizeRequestIds(recoverableLive);
1292
- ctx.log?.info('hook.answer', {
1293
- answered: false,
1294
- stage: 'queued',
1295
- request_ids: requestIdSummary.ids,
1296
- owner_spent: true,
1297
- });
1298
- notes.push(`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still recorded after the previous answer owner ended; a later turn will reconcile ${recoverableLive.length === 1 ? 'it' : 'them'} without starting another full-window wait`);
1299
- return { notes };
1300
- }
1301
- return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
1302
- }
1303
- // Questions registered after the earlier push still owe the user their
1304
- // notification; fall through and escalate just those, plus any live
1305
- // question whose owner lease is still running.
1306
- }
1307
- gate(ctx, 'proceeding', 'proceeding', {
1308
- unasked: unasked.length,
1309
- already_live: live.length,
1310
- grace_seconds: ctx.config.ask_grace_seconds.value,
1311
- });
1312
- return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
1313
- }
1314
- /** Submit independently of the long-lived answer owner, preserving frozen identities. */
1315
- export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, notes = []) {
1316
- const sessionId = envelope.session_id;
1317
- if (sessionId === undefined || sessionHasEnded(sessionId, ctx.env))
1318
- return [];
1319
- if (!ctx.config.ask_notifications.value) {
1320
- gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
1321
- return [];
1322
- }
1323
- const lock = `${sessionStatePath(sessionId, ctx.env)}.submission`;
1324
- // This lock spans admission only, never reply polling or harness delivery.
1325
- const lockDeadline = Math.min(hardDeadlineAt, ctx.now() + 60_000);
1326
- let token = acquireClaimFile(lock, {}, Date.now());
1327
- while (token === null && ctx.now() < lockDeadline && !sessionHasEnded(sessionId, ctx.env)) {
1328
- await ctx.sleep(Math.min(50, lockDeadline - ctx.now()));
1329
- token = acquireClaimFile(lock, {}, Date.now());
1330
- }
1331
- if (token === null) {
1332
- notes.push('question submission is already owned; the durable registration remains available for recovery');
1333
- return [];
1334
- }
1335
- try {
1336
- const ceilingAt = hardDeadlineAt;
1337
- const state = readSessionState(sessionId, ctx.env);
1338
- const pending = pendingList(state);
1339
- if (state.continuation !== undefined && state.continuation.count >= MAX_CONTINUATION_COUNT) {
1340
- gate(ctx, 'held', 'continuation-limit', { count: state.continuation.count, limit: MAX_CONTINUATION_COUNT });
1341
- notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
1342
- return [];
1343
- }
1344
- if (pending.length > 0 && pending.every((entry) => entry.request_id === undefined)) {
1345
- await awaitTerminalFirstWindow(ctx, Math.min(...pending.map((entry) => entry.asked_at ?? ctx.now())), ceilingAt);
1346
- }
1347
- // Phase one: every registered question reaches the user's devices, each as
1348
- // its own notification — one ask never stands in for another.
1349
- const submitted = [];
1350
- for (const entry of pendingList(readSessionState(sessionId, ctx.env)).filter((entry) => entry.request_id === undefined)) {
1351
- if (entry.service_identity !== undefined && (entry.service_identity.machine_id !== ctx.service_identity?.machine_id ||
1352
- entry.service_identity.base_url !== ctx.service_identity?.base_url)) {
1353
- notes.push('the registered question belongs to a different Approved Machine; preserving it without uploading');
1354
- continue;
1355
- }
1356
- if (!queuedQuestionStillEligible(sessionId, ctx.env, entry)) {
1357
- notes.push('the question was retired before submission; not uploading it');
1358
- continue;
1359
- }
1360
- // The service owns how long the answer is accepted. This process begins
1361
- // before submission, so its larger maximum-window budget includes startup
1362
- // headroom and remains alive through the complete committed window.
1363
- const replyWindowSeconds = ctx.config.reply_window_seconds.value;
1364
- if (ceilingAt - ctx.now() <
1365
- (replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000) {
1366
- notes.push('too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner');
1367
- continue;
1368
- }
1369
- const ownerDeadlineAt = ceilingAt;
1370
- const questions = pendingQuestions(entry);
1371
- const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
1372
- let intent = entry.submission;
1373
- if (intent === undefined) {
1374
- const prepared = await prepareQuestionSubmission(ctx, {
1375
- summary: entry.summary,
1376
- ...(entry.body !== undefined ? { body: entry.body } : {}),
1377
- questions,
1378
- ...(entry.media !== undefined ? { media: entry.media } : {}),
1379
- ...(entry.project !== undefined ? { project: entry.project } : {}),
1380
- ...(eventSource !== undefined ? { source: eventSource } : {}),
1381
- windowSeconds: replyWindowSeconds,
1382
- ownerDeadlineAt,
1383
- });
1384
- if ('error' in prepared) {
1385
- ctx.log?.error('hook.pushed', { ok: false, message: prepared.error });
1386
- notes.push(prepared.error);
1387
- continue;
1388
- }
1389
- if (entry.service_identity !== undefined)
1390
- prepared.service_identity = entry.service_identity;
1391
- intent = prepared;
1392
- // Durable before submit. If the server commits and the response is lost,
1393
- // the reserved request id still lets this owner poll and finalize the
1394
- // exact card; a successor can also replay the frozen draft/key.
1395
- updateSessionState(sessionId, ctx.env, (current) => {
1396
- const list = pendingList(current);
1397
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1398
- if (index < 0)
1399
- return current;
1400
- const next = [...list];
1401
- next[index] = { ...next[index], submission: prepared };
1402
- return { ...current, pending: next };
1403
- });
1404
- }
1405
- else if (intent.owner_deadline_at <= ctx.now()) {
1406
- // The frozen wire identity survives a crashed owner, but its local owner
1407
- // lease does not. Re-arm only the local deadline; request id, key,
1408
- // targets, and draft remain byte-identical for idempotent replay.
1409
- intent = { ...intent, owner_deadline_at: ceilingAt };
1410
- const rearmed = intent;
1411
- updateSessionState(sessionId, ctx.env, (current) => {
1412
- const list = pendingList(current);
1413
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1414
- if (index < 0)
1415
- return current;
1416
- const next = [...list];
1417
- next[index] = { ...next[index], submission: rearmed };
1418
- return { ...current, pending: next };
1419
- });
1420
- }
1421
- if (ctx.now() >= intent.owner_deadline_at) {
1422
- notes.push('the owner lifetime ended before submission; preserving the frozen intent');
1423
- continue;
1424
- }
1425
- if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1426
- notes.push('setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely');
1427
- continue;
1428
- }
1429
- if (sessionHasEnded(sessionId, ctx.env)) {
1430
- notes.push('the Agent Session ended before submission; preserving no live observer');
1431
- continue;
1432
- }
1433
- let receipt;
1434
- let admissionConfirmed = false;
1435
- try {
1436
- const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
1437
- if (attempt === null)
1438
- continue;
1439
- receipt = await attempt;
1440
- admissionConfirmed = true;
1441
- }
1442
- catch (caught) {
1443
- let err = caught;
1444
- if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
1445
- ctx.log?.error('hook.pushed', {
1446
- ok: false,
1447
- request_id: intent.request_id,
1448
- status: err.status,
1449
- code: err.code,
1450
- message: err.message,
1451
- });
1452
- if (entry.submission !== undefined) {
1453
- if (readSessionState(sessionId, ctx.env).native_answer_operations?.some(op => op.question_id === entry.question_id)) {
1454
- notes.push('the original submission was rejected; preserving its native answer operation without minting a replacement question');
1455
- continue;
1456
- }
1457
- notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
1458
- const reminted = await prepareQuestionSubmission(ctx, {
1459
- summary: entry.summary,
1460
- ...(entry.body !== undefined ? { body: entry.body } : {}),
1461
- questions,
1462
- ...(entry.media !== undefined ? { media: entry.media } : {}),
1463
- ...(entry.project !== undefined ? { project: entry.project } : {}),
1464
- ...(eventSource !== undefined ? { source: eventSource } : {}),
1465
- windowSeconds: replyWindowSeconds,
1466
- ownerDeadlineAt,
1467
- });
1468
- if ('error' in reminted) {
1469
- // Preserve the original intent; preparation can race an early
1470
- // native answer and is not proof that its obligation disappeared.
1471
- notes.push(reminted.error);
1472
- continue;
1473
- }
1474
- if (entry.service_identity !== undefined)
1475
- reminted.service_identity = entry.service_identity;
1476
- if (!replaceFrozenSubmission(sessionId, ctx.env, entry, intent, reminted)) {
1477
- notes.push('the original submission changed or has a native answer obligation; no replacement was admitted');
1478
- continue;
1479
- }
1480
- intent = reminted;
1481
- try {
1482
- if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1483
- notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
1484
- continue;
1485
- }
1486
- if (sessionHasEnded(sessionId, ctx.env)) {
1487
- notes.push('the Agent Session ended before recovered submission');
1488
- continue;
1489
- }
1490
- const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
1491
- if (attempt === null)
1492
- continue;
1493
- receipt = await attempt;
1494
- admissionConfirmed = true;
1495
- }
1496
- catch (retryErr) {
1497
- if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
1498
- dropPendingQuestion(sessionId, ctx.env, entry);
1499
- notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
1500
- ctx.log?.error('hook.pushed', {
1501
- ok: false,
1502
- request_id: intent.request_id,
1503
- status: retryErr.status,
1504
- code: retryErr.code,
1505
- message: retryErr.message,
1506
- });
1507
- continue;
1508
- }
1509
- err = retryErr;
1510
- }
1511
- }
1512
- else {
1513
- dropPendingQuestion(sessionId, ctx.env, entry);
1514
- notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
1515
- continue;
1516
- }
1517
- }
1518
- if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1519
- notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
1520
- ctx.log?.error('hook.pushed', {
1521
- ok: false,
1522
- request_id: intent.request_id,
1523
- status: err.status,
1524
- code: err.code,
1525
- message: err.message,
1526
- });
1527
- continue;
1528
- }
1529
- if (!admissionConfirmed) {
1530
- notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1531
- ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1532
- }
1533
- }
1534
- if (admissionConfirmed) {
1535
- ctx.log?.info('hook.pushed', {
1536
- ok: true,
1537
- request_id: intent.request_id,
1538
- devices: intent.device_ids.length,
1539
- questions: questions.length,
1540
- text_chars: questions[0].text.length,
1541
- });
1542
- }
1543
- const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
1544
- ? intent.owner_deadline_at
1545
- : Date.parse(receipt.reply_expires_at);
1546
- const live = {
1547
- ...entry,
1548
- ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
1549
- request_id: intent.request_id,
1550
- collapse_key: intent.collapse_key,
1551
- device_ids: intent.device_ids,
1552
- // The server's committed answer deadline is authoritative. The local
1553
- // owner deadline starts earlier and includes startup headroom, so it may
1554
- // be later but must never be earlier.
1555
- reply_deadline_at: Number.isFinite(committedReplyDeadline)
1556
- ? committedReplyDeadline
1557
- : intent.owner_deadline_at,
1558
- owner_deadline_at: intent.owner_deadline_at,
1559
- };
1560
- if (intent.draft.source === undefined)
1561
- delete live.source;
1562
- delete live.submission;
1563
- // Record what is now live on the user's devices BEFORE any wait. If we
1564
- // only learned these ids afterwards, a question that timed out would
1565
- // leave no trace, and the user returning to the terminal could never
1566
- // retire it — the notification would stay answerable for an hour with
1567
- // nobody listening.
1568
- if (admissionConfirmed) {
1569
- updateSessionState(sessionId, ctx.env, (current) => {
1570
- current = confirmNativeAnswerTarget(current, live);
1571
- const list = pendingList(current);
1572
- const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined &&
1573
- (candidate.service_identity === undefined || (candidate.service_identity.machine_id === ctx.service_identity?.machine_id &&
1574
- candidate.service_identity.base_url === ctx.service_identity?.base_url)));
1575
- if (index >= 0) {
1576
- const next = [...list];
1577
- next[index] = live;
1578
- return { ...current, pending: next };
1579
- }
1580
- // The entry vanished while the submit was in flight (the user's prompt
1581
- // wiped the queue). The delivered notification must still be retirable,
1582
- // so park it rather than lose its only identifiers.
1583
- const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
1584
- const retiring = [...(current.retiring ?? [])];
1585
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1586
- retiring.push(retirement);
1587
- }
1588
- return rememberQuestionState({ ...current, retiring }, live, 'retired');
1589
- });
1590
- }
1591
- if (sessionHasEnded(sessionId, ctx.env)) {
1592
- // SessionEnd may have raced either the submit or promotion. Its snapshot
1593
- // usually already queued the frozen intent; this idempotent add closes
1594
- // the other ordering where the request committed just after cleanup.
1595
- orphanRetirements(ctx.env, [retiringQuestion(live, 'expired', envelope.cwd)], ctx.now());
1596
- notes.push('the Agent Session ended during submission; queued the question for retirement');
1597
- continue;
1598
- }
1599
- if (!pendingList(readSessionState(sessionId, ctx.env)).some((candidate) => isSamePending(candidate, entry))) {
1600
- // A local close won after admission. Never resurrect the queue or wait
1601
- // for an answer to a retired question; settle its reserved remote id.
1602
- await drainRetirements(ctx, sessionId, ctx.env);
1603
- continue;
1604
- }
1605
- submitted.push(live);
1606
- }
1607
- return submitted;
1608
- }
1609
- finally {
1610
- releaseClaimFile(lock, token);
1611
- }
1612
- }
1613
- /** Merge this admission's uncertain receipts with fresh confirmed state. */
1614
- function observableQuestions(state, submitted, now) {
1615
- return pendingList(state).flatMap((entry) => {
1616
- if (entry.request_id !== undefined)
1617
- return ownerLeaseActive(entry, now) ? [entry] : [];
1618
- const attempted = submitted.findLast((candidate) => isSamePending(candidate, entry) && candidate.request_id === entry.submission?.request_id);
1619
- return attempted === undefined ? [] : [attempted];
1620
- });
1621
- }
1622
- /** The escalation itself, split out so the claim is released on every path. */
1623
- async function escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
1624
- const ceilingAt = hardDeadlineAt;
1625
- const attemptedLocal = new Set(pendingList(readSessionState(sessionId, ctx.env))
1626
- .filter((entry) => entry.request_id === undefined).map((entry) => entry.question_id ?? entry.question));
1627
- const submitted = await submitSessionQuestions(ctx, envelope, hardDeadlineAt, notes);
1628
- // Another submitter may have promoted a question while this owner waited.
1629
- // The durable queue, not either caller's earlier snapshot, is authoritative.
1630
- const live = observableQuestions(readSessionState(sessionId, ctx.env), submitted, ctx.now());
1631
- const anomalous = live.filter((entry) => entry.reply_deadline_at !== undefined &&
1632
- entry.reply_deadline_at > ceilingAt);
1633
- if (anomalous.length > 0)
1634
- notes.push('server committed an answer deadline beyond this process owner; reconciling the anomalous window');
1635
- const staleLive = live.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now() || anomalous.includes(entry));
1636
- const finalizedStale = await finalizePendings(ctx, sessionId, staleLive, answerCloseDisposition(ctx, route));
1637
- const staleAnswers = finalizedStale
1638
- .map(finalizedAnswer)
1639
- .filter((entry) => entry !== null);
1640
- const staleSilence = finalizedStale
1641
- .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
1642
- .map((entry) => entry.pending);
1643
- const staleUnproven = finalizedStale.filter((entry) => entry.response === null);
1644
- if (staleSilence.length > 0) {
1645
- await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired');
1646
- notes.push(`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`);
1647
- }
1648
- if (staleUnproven.length > 0) {
1649
- notes.push(`could not finalize ${staleUnproven.length} question${staleUnproven.length === 1 ? '' : 's'}; preserving the live question and observing for recovery`);
1650
- }
1651
- if (staleAnswers.length > 0) {
1652
- const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
1653
- const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
1654
- for (const answer of staleAnswers)
1655
- reportAnswer(ctx, notes, answer, true);
1656
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1657
- }
1658
- // A terminal close can race the remote close fence. Retain its remote identity
1659
- // for retirement without resurrecting a removed local question.
1660
- const remaining = pendingList(readSessionState(sessionId, ctx.env));
1661
- const removed = staleUnproven.filter(({ pending }) => !remaining.some((entry) => isSamePending(entry, pending)));
1662
- if (removed.length > 0)
1663
- await retirePendings(ctx, envelope, sessionId, removed.map(({ pending }) => pending), 'answered_elsewhere');
1664
- const waitingState = readSessionState(sessionId, ctx.env);
1665
- const waitingOn = observableQuestions(waitingState, submitted, ctx.now()).filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now());
1666
- if (waitingOn.length === 0)
1667
- return { notes };
1668
- // Codex has a durable exact-thread queue route. Release the short Stop hook
1669
- // after admission so a detached owner, independent of the TUI process, can
1670
- // observe the entire committed answer window. The owner lease and pending
1671
- // request identities remain in session state for that successor.
1672
- if (handoffToDetachedOwner) {
1673
- return { notes, settlementRequired: true };
1674
- }
1675
- // Phase two: keep the same owner alive across every committed answer window.
1676
- // Different questions may expire at different times, so finalize each one at
1677
- // its own server deadline and continue waiting on the rest. Reserving time by
1678
- // closing early would make the advertised reply window untrue.
1679
- let activeWaiting = waitingOn;
1680
- const knownRequestIds = new Set([
1681
- ...pendingList(waitingState).flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]),
1682
- ...waitingOn.map((entry) => entry.request_id),
1683
- ]);
1684
- let timeoutSeconds = 0;
1685
- let waited;
1686
- for (;;) {
1687
- const nextReplyDeadline = Math.min(...activeWaiting.map((entry) => entry.reply_deadline_at));
1688
- const waitDeadline = Math.min(ceilingAt, nextReplyDeadline);
1689
- timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000));
1690
- waited = await waitForAnyReply(ctx, activeWaiting.map((entry) => entry.request_id), timeoutSeconds, () => waiterInterruption(sessionId, ctx.env, [...knownRequestIds], attemptedLocal));
1691
- if (waited.interrupted === 'new-question') {
1692
- // Native observers can renew their process ceiling for a later question.
1693
- // A held Stop keeps its actual stdout owner and includes the new live ID.
1694
- if (route.kind === 'session-queue' || route.defer !== undefined) {
1695
- notes.push('yielding the answer owner to include the newly registered question');
1696
- return { notes, settlementRequired: true };
1697
- }
1698
- for (const entry of pendingList(readSessionState(sessionId, ctx.env))) {
1699
- if (entry.request_id === undefined)
1700
- attemptedLocal.add(entry.question_id ?? entry.question);
1701
- }
1702
- submitted.push(...await submitSessionQuestions(ctx, envelope, hardDeadlineAt, notes));
1703
- activeWaiting = observableQuestions(readSessionState(sessionId, ctx.env), submitted, ctx.now());
1704
- for (const entry of activeWaiting)
1705
- knownRequestIds.add(entry.request_id);
1706
- if (activeWaiting.length === 0)
1707
- return { notes };
1708
- continue;
1709
- }
1710
- if (waited.interrupted === 'ownership-ended') {
1711
- notes.push('answer ownership ended locally; stopping this observer without delivering into a closed session');
1712
- return { notes };
1713
- }
1714
- const currentRequestIds = new Set(pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) => [entry.request_id, entry.submission?.request_id].filter((requestId) => requestId !== undefined)));
1715
- const removedDuringPoll = activeWaiting.filter((entry) => !currentRequestIds.has(entry.request_id));
1716
- for (const entry of removedDuringPoll) {
1717
- waited.byRequest.delete(entry.request_id);
1718
- waited.permanentFailures.delete(entry.request_id);
1719
- }
1720
- if (removedDuringPoll.length > 0) {
1721
- notes.push(`${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`);
1722
- activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id));
1723
- if (activeWaiting.length === 0)
1724
- return { notes };
1725
- }
1726
- const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
1727
- if (permanentFailure !== null)
1728
- notes.push(permanentFailure);
1729
- if (waited.byRequest.size > 0)
1730
- break;
1731
- // A permanent rejection is authoritative immediately. Time-based expiry is
1732
- // authoritative only when this owner reached that question's committed
1733
- // server deadline; reaching the process ceiling first preserves the record.
1734
- const expired = activeWaiting.filter((entry) => entry.reply_deadline_at === undefined ||
1735
- entry.reply_deadline_at <= waitDeadline ||
1736
- waited.permanentFailures.has(entry.request_id));
1737
- const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
1738
- const finalized = await finalizePendings(ctx, sessionId, expired, answerCloseDisposition(ctx, route));
1739
- const finalAnswers = finalized
1740
- .map(finalizedAnswer)
1741
- .filter((entry) => entry !== null);
1742
- const confirmedSilent = finalized
1743
- .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
1744
- .map((entry) => entry.pending);
1745
- const unproven = finalized.filter((entry) => entry.response === null);
1746
- if (confirmedSilent.length > 0) {
1747
- await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired');
1748
- }
1749
- if (unproven.length > 0) {
1750
- notes.push(`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`);
1751
- }
1752
- if (finalAnswers.length > 0) {
1753
- const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length);
1754
- const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
1755
- for (const answer of finalAnswers)
1756
- reportAnswer(ctx, notes, answer, false);
1757
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1758
- }
1759
- if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
1760
- activeWaiting = stillAnswerable;
1761
- continue;
1762
- }
1763
- const requestIdSummary = summarizeRequestIds(waitingOn);
1764
- ctx.log?.info('hook.answer', {
1765
- answered: false,
1766
- stage: 'queued',
1767
- request_ids: requestIdSummary.ids,
1768
- degraded: waited.degraded,
1769
- permanent_failures: waited.permanentFailures.size,
1770
- waited_seconds: timeoutSeconds,
1771
- });
1772
- if (stillAnswerable.length > 0) {
1773
- notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`);
1774
- }
1775
- else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
1776
- notes.push(waited.degraded
1777
- ? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
1778
- : 'no answer in time; the question expired with its continuation owner');
1779
- }
1780
- return { notes };
1781
- }
1782
- const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
1783
- const finalizedAnswered = await finalizePendings(ctx, sessionId, polledAnswered, answerCloseDisposition(ctx, route));
1784
- const answered = [];
1785
- for (const finalized of finalizedAnswered) {
1786
- const entry = finalized.pending;
1787
- const replies = waited.byRequest.get(entry.request_id);
1788
- if (replies === undefined)
1789
- continue;
1790
- // The fenced set wins because it includes corrections that committed
1791
- // while close was waiting for earlier writers. If finalization itself was
1792
- // unreachable, never discard the durable reply already observed.
1793
- const finalReplies = finalized.response?.replies ?? [];
1794
- const authoritative = finalReplies.length > 0 ? finalReplies : replies;
1795
- answered.push({
1796
- pending: entry,
1797
- reply: authoritative.at(-1),
1798
- replies: authoritative,
1799
- agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
1800
- agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
1801
- ...claimMarker(finalized.response),
1802
- });
1803
- if (finalized.response === null) {
1804
- notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
1805
- }
1806
- }
1807
- const accepted = stageAcceptedAnswers(ctx, sessionId, answered, Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - answered.length));
1808
- for (const answer of answered)
1809
- reportAnswer(ctx, notes, answer, false);
1810
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1811
- }
1812
- // ---------------------------------------------------------------------------
1813
- // SessionEnd — local cleanup only
1814
- // ---------------------------------------------------------------------------
1815
- /**
1816
- * Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
1817
- * second, so this cannot make a network call. It drops the local marker — but
1818
- * first moves anything still live on the user's devices into the global
1819
- * retirement queue, because this file was the only record of those ids and no
1820
- * hook for this session will ever run again. A question whose
1821
- * agent just exited can receive no answer, so it is orphaned as `expired`.
1822
- */
1823
- export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccepted = true) {
1824
- const notes = [];
1825
- const sessionId = envelope.session_id;
1826
- if (!sessionId)
1827
- return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
1828
- // Publish cancellation before reading or clearing anything. In-flight Stop
1829
- // writers use the same session lock, so none can recreate state after this.
1830
- const ending = markSessionEnded(sessionId, env, now);
1831
- if (envelope.cwd !== undefined)
1832
- clearMatchingProjectSession(envelope.cwd, env, sessionId);
1833
- const state = readSessionState(sessionId, env);
1834
- let stateWithHistory = state;
1835
- for (const entry of pendingList(state)) {
1836
- stateWithHistory = rememberQuestionState(stateWithHistory, entry, entry.request_id === undefined && entry.submission === undefined ? 'withdrawn' : 'retired');
1837
- }
1838
- for (const answer of [...(state.accepted?.answers ?? []), ...(state.delivered_answers ?? []), ...(state.waiting_answers ?? [])]) {
1839
- stateWithHistory = rememberQuestionState(stateWithHistory, answer.pending, 'answered');
1840
- }
1841
- for (const retirement of state.retiring ?? []) {
1842
- stateWithHistory = rememberQuestionState(stateWithHistory, retirement, retirement.state === 'answered' ? 'answered' : 'retired');
1843
- }
1844
- const orphans = [...(state.retiring ?? [])];
1845
- const retirementCandidates = [
1846
- ...pendingList(state),
1847
- ...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
1848
- ...(state.delivered_answers?.map((entry) => entry.pending) ?? []),
1849
- ...(state.waiting_answers?.map((entry) => entry.pending) ?? []),
1850
- ];
1851
- for (const entry of retirementCandidates) {
1852
- try {
1853
- const orphan = retiringQuestion(entry, 'expired', envelope.cwd);
1854
- if (orphan !== null &&
1855
- !orphans.some((candidate) => candidate.request_id === orphan.request_id)) {
1856
- orphans.push(orphan);
1857
- }
1858
- }
1859
- catch (err) {
1860
- notes.push(err instanceof Error ? err.message : String(err));
1861
- // Preserve the only identifiers instead of turning an explicit corrupt
1862
- // state into an unretirable question during SessionEnd cleanup.
1863
- return {
1864
- notes,
1865
- log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
1866
- };
1867
- }
1868
- }
1869
- if (orphans.length > 0) {
1870
- orphanRetirements(env, orphans, now);
1871
- notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
1872
- }
1873
- return withFileLock(`${sessionStatePath(sessionId, env)}.lock`, () => {
1874
- const marker = readSessionEndMarker(sessionId, env);
1875
- if (marker?.stamp?.mono !== ending.stamp?.mono ||
1876
- (readSessionIncarnation(sessionId, env)?.key ?? null) !== ending.ends) {
1877
- return { notes, log: { outcome: 'preserved', reason: 'session-resumed-during-cleanup' } };
1878
- }
1879
- // A committed receipt can arrive after the end marker. Preserve the fresh
1880
- // obligation state rather than restoring the earlier cleanup snapshot.
1881
- const state = readSessionState(sessionId, env);
1882
- stateWithHistory = { ...state, ...(stateWithHistory.question_history === undefined ? {} : { question_history: stateWithHistory.question_history }) };
1883
- if ((preserveAccepted && state.accepted !== undefined) ||
1884
- (state.acknowledgement_due?.length ?? 0) > 0 ||
1885
- (state.message_acknowledgement_due?.length ?? 0) > 0 ||
1886
- (state.native_answer_operations?.length ?? 0) > 0 ||
1887
- (state.codex_question_bindings?.length ?? 0) > 0) {
1888
- const preserved = { ...stateWithHistory };
1889
- if (!preserveAccepted)
1890
- delete preserved.accepted;
1891
- if (!preserveAccepted)
1892
- delete preserved.waiting_answers;
1893
- delete preserved.input_wake;
1894
- delete preserved.pending;
1895
- delete preserved.retiring;
1896
- delete preserved.acknowledgement_blocks;
1897
- if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
1898
- delete preserved.acknowledgement_due;
1899
- }
1900
- if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
1901
- delete preserved.message_acknowledgement_due;
1902
- }
1903
- writeSessionStateUnlocked(sessionStatePath(sessionId, env), sessionId, preserved);
1904
- notes.push(preserveAccepted && state.accepted !== undefined
1905
- ? 'preserved an accepted device answer for this exact session to resume'
1906
- : 'preserved required Agent Acknowledgement obligations for this exact session');
1907
- return {
1908
- notes,
1909
- log: {
1910
- outcome: preserveAccepted && state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
1911
- queued_retirements: orphans.length,
1912
- accepted_answers: state.accepted?.answers.length ?? 0,
1913
- acknowledgement_due: state.acknowledgement_due?.length ?? 0,
1914
- ...(state.message_acknowledgement_due === undefined
1915
- ? {}
1916
- : { message_acknowledgement_due: state.message_acknowledgement_due.length }),
1917
- },
1918
- };
1919
- }
1920
- const history = stateWithHistory.question_history;
1921
- clearSessionStateUnlocked(sessionId, env);
1922
- if ((history?.length ?? 0) > 0) {
1923
- writeSessionStateUnlocked(sessionStatePath(sessionId, env), sessionId, { question_history: history });
1924
- }
1925
- return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } };
1926
- });
1927
- }
1928
- // ---------------------------------------------------------------------------
1929
- // ask — register a question for the turn's end
1930
- // ---------------------------------------------------------------------------
1931
- /**
1932
- * Registrations still waiting for their first push. More than this in one turn
1933
- * means an agent is looping, not asking, and related questions belong in one
1934
- * `ask --form` instead.
1935
- *
1936
- * Deliberately counts only unasked entries. A question already on the user's
1937
- * devices is waiting on a person, not on the agent, and an answer stays
1938
- * accepted for a day — so counting live questions here would have turned a
1939
- * patient user into a reason the agent could no longer ask anything.
1940
- */
1941
- export const MAX_PENDING_QUESTIONS = 4;
1942
- /**
1943
- * Everything this session has open at once, asked or not. The lock screen is
1944
- * the shared resource being protected here, not the agent's turn.
1945
- */
1946
- export const MAX_LIVE_QUESTIONS = 10;
1947
- export class QuestionRegistrationChanged extends Error {
1948
- reason;
1949
- constructor(reason) {
1950
- super(reason === 'credential'
1951
- ? 'The Approved Machine changed before question registration completed.'
1952
- : 'The Agent Session ended or changed before question registration completed.');
1953
- this.reason = reason;
1954
- }
1955
- }
1956
- /**
1957
- * A session may hold several registered questions at once: a new `ask` never
1958
- * ends an earlier one. Each reaches the user as its own notification and is
1959
- * answerable independently. Superseding is reply semantics — a later reply
1960
- * corrects an earlier reply to the same question (that correction model lives
1961
- * server-side) — never question semantics: the old single-slot model silently
1962
- * discarded a question the moment a second was registered, and the user was
1963
- * pointed at a notification that never existed (2026-08-09).
1964
- *
1965
- * The queue is keyed on the session, not the project: several agents may be
1966
- * running in one project at once, and their questions must never interfere.
1967
- */
1968
- export function registerQuestion(sessionId, env, question, now = Date.now(), nativeAdmission, guard) {
1969
- let full = null;
1970
- let stored = false;
1971
- const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
1972
- updateSessionState(sessionId, env, (state) => {
1973
- // Revalidate after acquiring the lock: discovery and lock contention can
1974
- // outlive the owner or its Approved Machine. Neither may adopt this ask.
1975
- if (guard !== undefined) {
1976
- if (!guard.machineStillMatches())
1977
- throw new QuestionRegistrationChanged('credential');
1978
- if (guard.owner_key !== undefined) {
1979
- const owner = readSessionIncarnation(sessionId, env);
1980
- if (owner?.key !== guard.owner_key || sessionHasEnded(sessionId, env) ||
1981
- owner.harness_process === undefined || processIdentityLiveness(owner.harness_process) !== 'alive') {
1982
- throw new QuestionRegistrationChanged('owner');
1983
- }
1984
- }
1985
- }
1986
- const pending = pendingList(state);
1987
- const unasked = pending.filter((entry) => entry.request_id === undefined);
1988
- if (unasked.length >= MAX_PENDING_QUESTIONS) {
1989
- full = 'unasked';
1990
- return state;
1991
- }
1992
- if (pending.length >= MAX_LIVE_QUESTIONS) {
1993
- full = 'live';
1994
- return state;
1995
- }
1996
- stored = true;
1997
- const registered = { asked_at: now, ...question, question_id: questionId, question: question.question.slice(0, MAX_STORED_QUESTION_CHARS) };
1998
- return reserveCurrentCodexQuestion({
1999
- ...state,
2000
- pending: [
2001
- ...pending,
2002
- registered,
2003
- ],
2004
- }, registered, sessionId, env, nativeAdmission);
2005
- });
2006
- if (full === 'unasked') {
2007
- throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
2008
- 'Combine related questions into one `notifai ask --form` instead of registering more.');
2009
- }
2010
- if (full === 'live') {
2011
- throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
2012
- 'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
2013
- }
2014
- // updateSessionState skips its callback when SessionEnd won the lock.
2015
- if (!stored)
2016
- throw new QuestionRegistrationChanged('owner');
2017
- return questionId;
2018
- }
2019
- /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
2020
- export function parseHookInput(raw) {
2021
- if (raw.trim() === '')
2022
- return {};
2023
- try {
2024
- const parsed = JSON.parse(raw);
2025
- return typeof parsed === 'object' && parsed !== null ? parsed : {};
2026
- }
2027
- catch {
2028
- return {};
2029
- }
2030
- }
2031
- //# sourceMappingURL=hook-lifecycle.js.map