@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
package/src/hooks.ts DELETED
@@ -1,3759 +0,0 @@
1
- import { randomBytes } from 'node:crypto'
2
- import {
3
- closeSync,
4
- existsSync,
5
- mkdirSync,
6
- openSync,
7
- readFileSync,
8
- readdirSync,
9
- rmSync,
10
- statSync,
11
- writeFileSync,
12
- } from 'node:fs'
13
- import path from 'node:path'
14
- import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
15
- import {
16
- LEGACY_QUESTION_CLAIM_TTL_SECONDS,
17
- QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS,
18
- QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS,
19
- QUESTION_WAITER_CEILING_SECONDS,
20
- } from './question-timing.js'
21
- import type {
22
- LifecycleEndState,
23
- ListRepliesResponse,
24
- MediaItemT,
25
- NotificationDraftT,
26
- QuestionT,
27
- ReplyView,
28
- SourceContextT,
29
- SubmissionReceipt,
30
- } from '@raidiant/notifai-protocol'
31
- import {
32
- ApiCallError,
33
- isRetryableReplyPollError,
34
- type ApiClient,
35
- } from './client.js'
36
- import {
37
- loadConfig,
38
- projectSessionPointerPath,
39
- sanitizeSessionId,
40
- sessionConfigPath,
41
- stateDir,
42
- type CliConfig,
43
- } from './config.js'
44
- import { buildDraft } from './send.js'
45
- import { atomicWriteFileSync } from './atomic-file.js'
46
- import { withFileLock } from './file-lock.js'
47
- import { inferInvocationContext } from './invocation-context.js'
48
- import type { Logger } from './logging.js'
49
- import {
50
- HARNESS_CAPABILITIES,
51
- HOOK_INSTALLABLE_HARNESSES,
52
- type DeliveryRoute,
53
- type HookInstallableHarness,
54
- } from './harnesses.js'
55
-
56
- /**
57
- * Harness hook handlers.
58
- *
59
- * The supported harnesses expose the same useful lifecycle joints: a turn-end
60
- * event and an event that fires when the user submits a prompt. Claude Code
61
- * and Codex can continue directly from a turn-end answer. Harnesses without a
62
- * proven exact-session continuation fail closed at question admission.
63
- *
64
- * Where the user is standing no longer decides anything here. It used to: the
65
- * old turn-end route held the terminal briefly and made keyboard presence an
66
- * input to whether it waited. The current policy is explicit instead: Claude
67
- * Code owns the complete answer window out of band, while Codex owns it by
68
- * holding the asking turn. Neither infers notification preference from
69
- * keystrokes.
70
- */
71
-
72
- /** Fields we read from harness hook JSON. Everything else is passed through. */
73
- export interface HookEnvelope {
74
- session_id?: string
75
- cwd?: string
76
- hook_event_name?: string
77
- /** How a harness lifecycle began: startup, resume, clear, compact, or fork. */
78
- source?: string
79
- /** Set by the harness when this Stop follows a previous Stop continuation. */
80
- stop_hook_active?: boolean
81
- /** Cursor's stable per-conversation identifier. */
82
- conversation_id?: string
83
- /** Cursor's project roots; the first is the hook's configuration root. */
84
- workspace_roots?: string[]
85
- /** Cursor increments this after each stop-hook automatic follow-up. */
86
- loop_count?: number
87
- /** Cursor Stop completion state; cancellation must not auto-follow. */
88
- status?: string
89
- /** The prompt the user just submitted, when the harness includes it. */
90
- prompt?: string
91
- }
92
-
93
- export interface SessionState {
94
- /** Harness that owns this exact lifecycle state. */
95
- harness?: HookHarness
96
- /** Checkout whose hook definition activated this session; lifecycle diagnostics only. */
97
- activation_cwd?: string
98
- /** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
99
- cursor_activation_claimed_at?: number
100
- cursor_activation_confirmed_at?: number
101
- /** Epoch ms of the user's last prompt in this session — our presence signal. */
102
- last_prompt_at?: number
103
- /** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
104
- last_stop_at?: number
105
- /**
106
- * Questions registered by `notifai ask`, in registration order, each
107
- * awaiting escalation or its answer. A list, deliberately: registering a
108
- * question never ends an earlier one. Superseding is reply semantics — a
109
- * later reply corrects an earlier reply to the same question — never
110
- * question semantics; the single-slot model silently discarded a live
111
- * question the moment a second was registered (2026-08-09).
112
- */
113
- pending?: PendingQuestion[]
114
- /**
115
- * Questions that have been delivered to the user's devices and are now dead,
116
- * but whose retirement has not been confirmed yet.
117
- *
118
- * A retirement needs a network call and the moment we learn a question is
119
- * dead is not always a moment we can make one — the user's return to the
120
- * terminal is observed by a bare hook, and the machine may be offline.
121
- * Dropping the ids there is how a delivered question becomes permanently
122
- * unretirable, so they are parked here instead and every later hook with a
123
- * client drains them. Retirement is idempotent, so a duplicate attempt
124
- * costs nothing and a missed one costs a stale notification for ever.
125
- */
126
- retiring?: RetiringQuestion[]
127
- /**
128
- * Tracks bounded Stop continuations so a follow-up ask is delivered once.
129
- * `count` is how many answer generations have run consecutively without the
130
- * user taking a turn themselves; their next prompt starts it over.
131
- */
132
- continuation?: {
133
- answered_at: number
134
- count: number
135
- }
136
- /**
137
- * A phone answer durably captured but not yet acknowledged. It stays here
138
- * until a delivery is acknowledged — by the route's own write, or by the
139
- * successor Stop of a blocking continuation — so a crash before the answer
140
- * reaches the harness replays it instead of erasing it.
141
- */
142
- accepted?: AcceptedAnswerDelivery
143
- /**
144
- * Required Agent Acknowledgements that the resumed agent still owes. This is
145
- * separate from answer delivery: the answer journal may settle as soon as a
146
- * harness accepts the continuation, while this obligation must survive until
147
- * the service confirms the agent-authored follow-up exists.
148
- */
149
- acknowledgement_due?: AcknowledgementDue[]
150
- /** Consecutive turns this session has been held for an acknowledgement. */
151
- acknowledgement_blocks?: number
152
- }
153
-
154
- export interface AcknowledgementDue {
155
- request_id: string
156
- recorded_at: number
157
- /**
158
- * Whether this request's acknowledgement must carry text. Absent on state
159
- * written before the obligation started recording it; treated as true, which
160
- * is the default the service ships.
161
- */
162
- text_required?: boolean
163
- }
164
-
165
- interface AcceptedAnswerDelivery {
166
- answers: AnsweredPending[]
167
- remaining: number
168
- recorded_at: number
169
- /**
170
- * Epoch ms when a route's own write handed this answer to the harness, with
171
- * the route that performed it. Recorded so that "this answer was delivered"
172
- * is a fact on the journal rather than something inferred from a harness flag
173
- * that only one route ever sets.
174
- */
175
- delivered_at?: number
176
- delivered_route?: string
177
- /**
178
- * How many times this answer has been handed to any route. The route-agnostic
179
- * loop backstop: bounded by `MAX_CONTINUATION_COUNT` in one place every route
180
- * passes through.
181
- */
182
- delivery_attempts?: number
183
- /**
184
- * Linearization point between delivery and SessionEnd. Once recorded, the
185
- * route began first; before it, SessionEnd cancellation wins.
186
- */
187
- delivery_committed_at?: number
188
- /** Turns this answer has been held without being handed to the agent. */
189
- held_deliveries?: number
190
- }
191
-
192
- /** A delivered question awaiting its retirement push. */
193
- export interface RetiringQuestion {
194
- request_id: string
195
- collapse_key: string
196
- /** The Device Installations that actually received the question. */
197
- device_ids: string[]
198
- /** Shown if the companion has no history entry to correlate against. */
199
- question: string
200
- project?: string
201
- source?: SourceContextT
202
- state: LifecycleEndState
203
- }
204
-
205
- /**
206
- * A retirement that outlived its session.
207
- *
208
- * Per-session parking assumes some later hook in the SAME session will hold a
209
- * client, and `SessionEnd` is exactly where that assumption breaks: it may not
210
- * touch the network, and no hook for that session ever fires again. Deleting
211
- * the state there lost the only copy of the delivered question's ids, so the
212
- * phone kept an answerable question nobody was listening to. These entries are
213
- * moved to a machine-global queue instead, drained by whichever session's hook
214
- * next holds a client.
215
- */
216
- export interface OrphanRetirement extends RetiringQuestion {
217
- /** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
218
- enqueued_at: number
219
- }
220
-
221
- /**
222
- * A retirement is only worth syncing while the question it retires could still
223
- * be answered, so this has to outlive the longest reply window a user can
224
- * choose — it is derived from that bound rather than from a guess about how
225
- * long questions stay open, which is exactly the assumption that broke when
226
- * the window stopped being one hour. Past it the companion shows the question
227
- * as dead on next open anyway. Also the backstop that keeps an unreachable
228
- * server from growing the queue for ever.
229
- */
230
- const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000
231
-
232
- /** More orphans than this means something is looping; keep the newest. */
233
- const ORPHAN_QUEUE_CAP = 50
234
-
235
- export interface PendingQuestion {
236
- /** Stable local identity across racing state writers and submit recovery. */
237
- question_id?: string
238
- /** One-line summary: the single question's text, or the set's first. */
239
- question: string
240
- /**
241
- * Epoch ms when `notifai ask` registered this. The grace window runs from
242
- * here, not from the turn's end: a question the agent asked five minutes ago
243
- * while it kept working has already served its wait in the terminal.
244
- */
245
- asked_at?: number
246
- /**
247
- * The full question set as `notifai ask` validated it: generated ids,
248
- * texts, choices, multi flags. What actually rides the push.
249
- */
250
- questions?: QuestionT[]
251
- /** Canonical Markdown body composed when the question was registered. */
252
- body?: string
253
- /** Final ordered media collection; uploads complete before registration. */
254
- media?: MediaItemT[]
255
- /** Final Project and Source Context frozen at registration. */
256
- project?: string
257
- source?: SourceContextT
258
- /** Set once the question has actually been pushed, so it can be retired. */
259
- request_id?: string
260
- collapse_key?: string
261
- /** Exact fanout of the live question; routing config may change afterwards. */
262
- device_ids?: string[]
263
- /** Absolute end of the server reply window. */
264
- reply_deadline_at?: number
265
- /**
266
- * Absolute process-owner deadline. It begins before submission and includes
267
- * startup headroom, so it must be later than `reply_deadline_at`. Absent on
268
- * state written before ownership was persisted — treated as already spent
269
- * instead of inventing a fresh multi-day claim.
270
- */
271
- owner_deadline_at?: number
272
- /** Frozen before the first network byte, so an ambiguous submit is replayable. */
273
- submission?: PendingSubmissionIntent
274
- }
275
-
276
- /** Keep immutable session grouping while replacing per-event Git location authoritatively. */
277
- function sourceContextAtHookEvent(
278
- source: SourceContextT | undefined,
279
- cwd: string | undefined,
280
- ): SourceContextT | undefined {
281
- const invocation = cwd === undefined ? null : inferInvocationContext(cwd)
282
- const current: SourceContextT = {
283
- ...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
284
- ...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
285
- ...(source?.harness !== undefined ? { harness: source.harness } : {}),
286
- ...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
287
- ...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
288
- }
289
- return Object.keys(current).length === 0 ? undefined : current
290
- }
291
-
292
- interface PendingSubmissionIntent {
293
- request_id: string
294
- idempotency_key: string
295
- collapse_key: string
296
- device_ids: string[]
297
- draft: NotificationDraftT
298
- owner_deadline_at: number
299
- }
300
-
301
- function summarizeRequestIds(entries: readonly PendingQuestion[]): {
302
- ids: string[]
303
- display: string
304
- } {
305
- const ids = entries.flatMap((entry) =>
306
- entry.request_id === undefined ? [] : [entry.request_id],
307
- )
308
- return { ids, display: ids.join(', ') }
309
- }
310
-
311
- /**
312
- * The wire contract's shortest reply window. A question submitted with less
313
- * than this is rejected outright, and accepting one would in any case let the
314
- * server go on taking an answer after the waiter that owns it has returned.
315
- */
316
- export const MIN_REPLY_WINDOW_SECONDS = 60
317
-
318
- /**
319
- * The terminal-first wait: the question sits in the terminal for
320
- * `ask_grace_seconds` from when it was sent, and only then reaches companion
321
- * devices.
322
- *
323
- * A plain timer, and nothing else. It once polled an idle signal so it could
324
- * abandon the wait the moment the user touched the keyboard — necessary while
325
- * the wait blocked the terminal, because a user wanting to answer locally was
326
- * otherwise locked out of their own prompt. Presence no longer changes this
327
- * deterministic timer; whether the harness holds or wakes is a separate route
328
- * decision.
329
- *
330
- * The window is measured from registration, not from the turn's end: a
331
- * question the agent asked five minutes ago while it kept working has already
332
- * served its wait. A stamp from the future would otherwise consume the whole
333
- * ceiling and leave no room to answer, so it starts no later than now.
334
- */
335
- async function awaitTerminalFirstWindow(
336
- ctx: HookContext,
337
- askedAt: number,
338
- ceilingAt: number,
339
- ): Promise<void> {
340
- const graceSeconds = ctx.config.ask_grace_seconds.value
341
- if (graceSeconds <= 0) return
342
- const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt)
343
- const remaining = until - ctx.now()
344
- if (remaining > 0) await ctx.sleep(remaining)
345
- }
346
-
347
- export interface HookOutcome {
348
- /** Written to stdout verbatim — the harness parses this as output. */
349
- stdout?: string
350
- /** Commit a blocking continuation immediately before the harness stdout write. */
351
- commitStdout?: () => boolean
352
- /** Whether stdout takes over the turn, rather than adding prompt context. */
353
- decided?: boolean
354
- /** Diagnostics; harnesses surface hook stderr in the transcript. */
355
- notes: string[]
356
- /** Structured lifecycle detail that belongs on hook.end without user text. */
357
- log?: Record<string, unknown>
358
- }
359
-
360
- /** An accepted continuation ready for whichever host owns the last meter. */
361
- export interface ContinuationEvent {
362
- context: string
363
- answers: number
364
- remaining: number
365
- request_ids: string[]
366
- journal_recorded_at: number
367
- /**
368
- * Must be called immediately before the route's irreversible harness write.
369
- * It atomically orders that write against SessionEnd; false means cancellation
370
- * won and the route must hand nothing over.
371
- */
372
- commitDelivery(): boolean
373
- }
374
-
375
- /**
376
- * How much a route's own return proves about where the answer ended up.
377
- *
378
- * Acknowledgement belongs to the delivery, not to the harness envelope: routes
379
- * end in different places, so no single field on a hook payload can speak for
380
- * all of them. `stop_hook_active` is the harness confirming that a *blocking
381
- * continuation* was admitted; it stays false for ever on a route that starts a
382
- * brand-new turn instead of continuing this one, so a journal keyed to it alone
383
- * never settles and every later turn-end redelivers the same answer.
384
- *
385
- * - `delivered` — the route completed a write to the harness itself (an inbox
386
- * socket, a cold resume). Delivery is not consumption: the write proves the
387
- * harness accepted the message, never that the model acted on it. But nothing
388
- * later will ever prove more, and redelivering an answer without end is
389
- * strictly worse than settling on the write, so the journal settles here.
390
- * - `stdout` — the answer is this process's stdout, which the harness reads only
391
- * after this process exits. Nothing this process can write is proof, so the
392
- * journal waits for the successor Stop's `stop_hook_active`, and a crash
393
- * before stdout replays the answer instead of losing it.
394
- * - `held` — the route handed nothing over; the journal replays the answer.
395
- */
396
- export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
397
-
398
- /** What a route returns: the hook's outcome plus what the attempt proved. */
399
- export interface DeliveryOutcome {
400
- stdout?: string
401
- /** Deferred SessionEnd fence for a blocking stdout continuation. */
402
- commitStdout?: () => boolean
403
- notes?: string[]
404
- log?: Record<string, unknown>
405
- acknowledgement: DeliveryAcknowledgement
406
- }
407
-
408
- /** Host adapter injected into the waiter; no route is implemented by the waiter. */
409
- export interface EscalationDeliveryRoute {
410
- kind: Exclude<DeliveryRoute, 'unsupported'>
411
- deliver(event: ContinuationEvent): Promise<DeliveryOutcome>
412
- }
413
-
414
- export interface EscalationWaiterOptions {
415
- sessionId: string
416
- envelope: HookEnvelope
417
- route: EscalationDeliveryRoute
418
- processDeadlineAt?: number
419
- }
420
-
421
- function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
422
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
423
- }
424
-
425
- /**
426
- * Durable cancellation for observers that can outlive the harness process.
427
- *
428
- * Absence of session state is not enough: SessionEnd deliberately deletes it,
429
- * and an in-flight submit/fence could otherwise recreate it afterwards. The
430
- * marker shares the session-state lock, giving every racing writer one total
431
- * order: either its state lands before SessionEnd and is cleaned up there, or
432
- * it observes this marker and must not write or deliver into the ended session.
433
- */
434
- function sessionEndMarkerPath(sessionId: string, env: NodeJS.ProcessEnv): string {
435
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`)
436
- }
437
-
438
- function sessionHasEnded(sessionId: string, env: NodeJS.ProcessEnv): boolean {
439
- return existsSync(sessionEndMarkerPath(sessionId, env))
440
- }
441
-
442
- function markSessionEnded(sessionId: string, env: NodeJS.ProcessEnv, now: number): void {
443
- const stateFile = sessionStatePath(sessionId, env)
444
- withFileLock(`${stateFile}.lock`, () => {
445
- atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`)
446
- })
447
- }
448
-
449
- function clearSessionEndMarker(sessionId: string, env: NodeJS.ProcessEnv): void {
450
- const stateFile = sessionStatePath(sessionId, env)
451
- withFileLock(`${stateFile}.lock`, () => {
452
- rmSync(sessionEndMarkerPath(sessionId, env), { force: true })
453
- })
454
- }
455
-
456
- export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState {
457
- const file = sessionStatePath(sessionId, env)
458
- if (!existsSync(file)) return {}
459
- try {
460
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
461
- if (typeof parsed !== 'object' || parsed === null) return {}
462
- const state = { ...(parsed as SessionState & { session_id?: unknown }) }
463
- delete state.session_id
464
- return state
465
- } catch {
466
- // A corrupt marker must not wedge the harness; treat it as "no evidence",
467
- // which fails closed onto ordinary terminal behaviour.
468
- return {}
469
- }
470
- }
471
-
472
- /**
473
- * Resolve a stable question or request id to the one exact session that owns it.
474
- *
475
- * This deliberately searches lifecycle state, not checkout pointers: question
476
- * identity is global on this machine, while a linked worktree is only where a
477
- * command happened to run. Ambiguous or corrupt matches fail closed.
478
- */
479
- export function findOwningSession(
480
- id: string,
481
- env: NodeJS.ProcessEnv,
482
- ): { sessionId: string | null; ambiguous: boolean } {
483
- const directory = path.join(stateDir(env), 'sessions')
484
- if (!existsSync(directory)) return { sessionId: null, ambiguous: false }
485
- const matches: string[] = []
486
- for (const name of readdirSync(directory)) {
487
- if (!name.endsWith('.json')) continue
488
- let persisted: (SessionState & { session_id?: unknown }) | null = null
489
- try {
490
- const parsed: unknown = JSON.parse(readFileSync(path.join(directory, name), 'utf8'))
491
- if (typeof parsed === 'object' && parsed !== null) {
492
- persisted = parsed as SessionState & { session_id?: unknown }
493
- }
494
- } catch {
495
- // Corrupt lifecycle state is not identity evidence.
496
- }
497
- if (persisted === null || typeof persisted.session_id !== 'string') continue
498
- const sessionId = persisted.session_id
499
- const state = persisted
500
- const pendingMatch = pendingList(state).some(
501
- (entry) => entry.question_id === id || entry.request_id === id,
502
- )
503
- const retiringMatch = (state.retiring ?? []).some((entry) => entry.request_id === id)
504
- const acknowledgementMatch = (state.acknowledgement_due ?? []).some(
505
- (entry) => entry.request_id === id,
506
- )
507
- const acceptedMatch = state.accepted?.answers.some(
508
- ({ pending }) => pending.question_id === id || pending.request_id === id,
509
- ) ?? false
510
- if (pendingMatch || retiringMatch || acknowledgementMatch || acceptedMatch) {
511
- matches.push(sessionId)
512
- if (matches.length > 1) return { sessionId: null, ambiguous: true }
513
- }
514
- }
515
- return { sessionId: matches[0] ?? null, ambiguous: false }
516
- }
517
-
518
- export function writeSessionState(
519
- sessionId: string,
520
- env: NodeJS.ProcessEnv,
521
- state: SessionState,
522
- ): void {
523
- const file = sessionStatePath(sessionId, env)
524
- withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state))
525
- }
526
-
527
- export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
528
- const file = sessionStatePath(sessionId, env)
529
- withFileLock(`${file}.lock`, () => {
530
- rmSync(file, { force: true })
531
- // The session override lives in a sibling file; leaving it behind meant a
532
- // later session reusing the id silently inherited `ask_notifications = false`.
533
- rmSync(sessionConfigPath(sessionId, env), { force: true })
534
- })
535
- }
536
-
537
- function writeSessionStateUnlocked(file: string, sessionId: string, state: SessionState): void {
538
- atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`)
539
- }
540
-
541
- function updateSessionState(
542
- sessionId: string,
543
- env: NodeJS.ProcessEnv,
544
- update: (current: SessionState) => SessionState,
545
- ): SessionState {
546
- const file = sessionStatePath(sessionId, env)
547
- return withFileLock(`${file}.lock`, () => {
548
- const current = readSessionState(sessionId, env)
549
- // SessionEnd and every asynchronous writer share this lock. Once the
550
- // durable marker exists, no observer may recreate state for that session.
551
- if (sessionHasEnded(sessionId, env)) return current
552
- const next = update(current)
553
- writeSessionStateUnlocked(file, sessionId, next)
554
- return next
555
- })
556
- }
557
-
558
- /** Record the current installation when an owner session starts. */
559
- export function recordSessionStart(
560
- sessionId: string,
561
- env: NodeJS.ProcessEnv,
562
- harness?: HookHarness,
563
- cwd?: string,
564
- ): void {
565
- // Harnesses may reuse a session id only by explicitly starting that session
566
- // again. That lifecycle edge is the sole authority for clearing cancellation.
567
- clearSessionEndMarker(sessionId, env)
568
- updateSessionState(sessionId, env, (current) => ({
569
- ...current,
570
- ...(harness === undefined ? {} : { harness }),
571
- ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
572
- }))
573
- }
574
-
575
- /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
576
- export function claimCursorStopActivation(
577
- sessionId: string,
578
- env: NodeJS.ProcessEnv,
579
- now: number,
580
- ): boolean {
581
- const claimGuardMs = 30_000
582
- let claimed = false
583
- updateSessionState(sessionId, env, (current) => {
584
- if (current.cursor_activation_confirmed_at !== undefined) return current
585
- if (
586
- current.cursor_activation_claimed_at !== undefined &&
587
- now - current.cursor_activation_claimed_at < claimGuardMs
588
- ) {
589
- return current
590
- }
591
- claimed = true
592
- return { ...current, cursor_activation_claimed_at: now }
593
- })
594
- return claimed
595
- }
596
-
597
- /** Cursor's loop_count proves the previous Stop follow-up reached the host. */
598
- export function confirmCursorStopActivation(
599
- sessionId: string,
600
- env: NodeJS.ProcessEnv,
601
- now: number,
602
- ): boolean {
603
- let owned = false
604
- updateSessionState(sessionId, env, (current) => {
605
- if (current.cursor_activation_confirmed_at !== undefined) {
606
- owned = true
607
- return current
608
- }
609
- if (current.cursor_activation_claimed_at === undefined) return current
610
- owned = true
611
- return { ...current, cursor_activation_confirmed_at: now }
612
- })
613
- return owned
614
- }
615
-
616
- /** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
617
- export function resetCursorStopActivation(sessionId: string, env: NodeJS.ProcessEnv): void {
618
- updateSessionState(sessionId, env, (current) => {
619
- if (
620
- current.cursor_activation_claimed_at === undefined &&
621
- current.cursor_activation_confirmed_at === undefined
622
- ) {
623
- return current
624
- }
625
- const next = { ...current }
626
- delete next.cursor_activation_claimed_at
627
- delete next.cursor_activation_confirmed_at
628
- return next
629
- })
630
- }
631
-
632
- /**
633
- * Session state a crashed harness left behind.
634
- *
635
- * `SessionEnd` removes session state and leaves a short-lived cancellation
636
- * marker; a harness that crashes or is killed never reaches it. At roughly a
637
- * hundred sessions a day that is tens of thousands of files a year, none of
638
- * which anything reads.
639
- *
640
- * Opportunistic rather than scheduled: hooks are the only thing that runs, so
641
- * a hook is where this has to live. It is rate-limited by its own stamp file
642
- * so the common case costs one `stat`, not a directory walk — a Stop hook is
643
- * on the critical path of every turn.
644
- */
645
- const SESSION_PRUNE_AFTER_MS = 7 * 24 * 3600 * 1000
646
- const SESSION_PRUNE_INTERVAL_MS = 24 * 3600 * 1000
647
-
648
- export function pruneAbandonedSessions(
649
- env: NodeJS.ProcessEnv,
650
- now: number = Date.now(),
651
- maxAgeMs: number = SESSION_PRUNE_AFTER_MS,
652
- ): number {
653
- const directory = path.join(stateDir(env), 'sessions')
654
- const stamp = path.join(stateDir(env), 'last-prune')
655
- try {
656
- if (existsSync(stamp) && now - statSync(stamp).mtimeMs < SESSION_PRUNE_INTERVAL_MS) return 0
657
- mkdirSync(path.dirname(stamp), { recursive: true })
658
- writeFileSync(stamp, '', { mode: 0o600 })
659
- if (!existsSync(directory)) return 0
660
-
661
- let removed = 0
662
- for (const name of readdirSync(directory)) {
663
- const file = path.join(directory, name)
664
- try {
665
- const age = now - statSync(file).mtimeMs
666
- // A negative age means the clock moved, not that the file is old.
667
- // Deleting live session state on an NTP correction would lose a
668
- // question already on the user's phone.
669
- if (age <= maxAgeMs) continue
670
- rmSync(file, { force: true })
671
- removed += 1
672
- } catch {
673
- // A file that vanished under us, or one we may not read. Neither is
674
- // worth failing a hook for, and the next pass will see it again.
675
- }
676
- }
677
- return removed
678
- } catch {
679
- // Housekeeping must never be the reason a turn fails.
680
- return 0
681
- }
682
- }
683
-
684
- /**
685
- * One question, one push, even with two Stop hooks racing.
686
- *
687
- * Path-independent hook ownership stops the *usual* cause of two handlers
688
- * firing, but it cannot stop every one — two harnesses in one directory, or an
689
- * install this build does not recognise. Both processes would read the same
690
- * pending question, see no `request_id`, and both escalate: one question, two
691
- * notifications.
692
- *
693
- * Exclusive create is atomic on POSIX, so exactly one process gets the claim
694
- * and the other steps aside. A short guard serializes stale replacement with
695
- * contenders and releases; random ownership tokens keep an old holder from
696
- * unlinking its replacement. A live PID owns the claim regardless of age; a
697
- * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
698
- * no trustworthy PID fall back to an age limit.
699
- */
700
- const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000
701
-
702
- /**
703
- * How long a crashed claim *guard* blocks the next hook.
704
- *
705
- * The claim itself may legitimately be held for the whole waiter, so it takes
706
- * that ceiling. The guard is a short lock around the claim's own bookkeeping —
707
- * a few file operations — and borrowing the waiter's ceiling for it meant a
708
- * hook killed at exactly the wrong moment wedged this session's Stop path for
709
- * eight minutes, for a critical section that never runs longer than a second.
710
- */
711
- const CLAIM_GUARD_TTL_MS = 30_000
712
- const heldClaims = new Map<string, string>()
713
-
714
- export function claimQuestionPush(
715
- sessionId: string,
716
- env: NodeJS.ProcessEnv,
717
- now: number = Date.now(),
718
- beforeStaleReplace?: () => void,
719
- ownerDeadlineAt?: number,
720
- ): boolean {
721
- const file = claimPath(sessionId, env)
722
- const guard = `${file}.guard`
723
- const token = randomBytes(12).toString('base64url')
724
- const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(
725
- claimQuestionIdentity,
726
- )
727
- mkdirSync(path.dirname(file), { recursive: true })
728
- if (!acquireClaimGuard(guard)) return false
729
- try {
730
- try {
731
- writeFileSync(
732
- file,
733
- `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
734
- {
735
- mode: 0o600,
736
- flag: 'wx',
737
- },
738
- )
739
- heldClaims.set(file, token)
740
- return true
741
- } catch {
742
- // Held. Break it only if whoever holds it cannot still be running.
743
- try {
744
- const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown; pid?: unknown }
745
- const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
746
- if (typeof held.pid === 'number') {
747
- if (processIsAlive(held.pid)) return false
748
- } else if (age >= 0 && age < CLAIM_TTL_MS) {
749
- return false
750
- }
751
- } catch {
752
- // Unreadable or corrupt: treat as abandoned.
753
- }
754
- beforeStaleReplace?.()
755
- rmSync(file, { force: true })
756
- try {
757
- writeFileSync(
758
- file,
759
- `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`,
760
- {
761
- mode: 0o600,
762
- flag: 'wx',
763
- },
764
- )
765
- heldClaims.set(file, token)
766
- return true
767
- } catch {
768
- return false
769
- }
770
- }
771
- } finally {
772
- rmSync(guard, { force: true })
773
- }
774
- }
775
-
776
- function claimQuestionIdentity(entry: PendingQuestion): string {
777
- return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`
778
- }
779
-
780
- /** True only when the live owner could not have snapshotted this unpushed ask. */
781
- function claimHandoffState(
782
- sessionId: string,
783
- env: NodeJS.ProcessEnv,
784
- ): { hasNewQuestion: boolean; ownerDeadlineAt?: number } {
785
- const unasked = pendingList(readSessionState(sessionId, env)).filter(
786
- (entry) => entry.request_id === undefined,
787
- )
788
- if (unasked.length === 0) return { hasNewQuestion: false }
789
- try {
790
- const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8')) as {
791
- pending_question_ids?: unknown
792
- owner_deadline_at?: unknown
793
- }
794
- if (!Array.isArray(held.pending_question_ids)) return { hasNewQuestion: false }
795
- const snapshotted = new Set(
796
- held.pending_question_ids.filter((entry): entry is string => typeof entry === 'string'),
797
- )
798
- return {
799
- hasNewQuestion: unasked.some(
800
- (entry) => !snapshotted.has(claimQuestionIdentity(entry)),
801
- ),
802
- ...(typeof held.owner_deadline_at === 'number' &&
803
- Number.isFinite(held.owner_deadline_at)
804
- ? { ownerDeadlineAt: held.owner_deadline_at }
805
- : {}),
806
- }
807
- } catch {
808
- return { hasNewQuestion: false }
809
- }
810
- }
811
-
812
- function processIsAlive(pid: number): boolean {
813
- try {
814
- process.kill(pid, 0)
815
- return true
816
- } catch (err) {
817
- return (err as NodeJS.ErrnoException).code === 'EPERM'
818
- }
819
- }
820
-
821
- export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
822
- const file = claimPath(sessionId, env)
823
- const token = heldClaims.get(file)
824
- if (token === undefined) return
825
- const guard = `${file}.guard`
826
- if (!acquireClaimGuard(guard)) {
827
- heldClaims.delete(file)
828
- return
829
- }
830
- try {
831
- try {
832
- const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
833
- if (held.token === token) rmSync(file, { force: true })
834
- } catch {
835
- // Missing or replaced claims are not ours to release.
836
- }
837
- } finally {
838
- heldClaims.delete(file)
839
- rmSync(guard, { force: true })
840
- }
841
- }
842
-
843
- /** The guard serializes stale replacement and release; recover a crashed guard. */
844
- function acquireClaimGuard(file: string): boolean {
845
- for (let attempt = 0; attempt < 2; attempt += 1) {
846
- try {
847
- const handle = openSync(file, 'wx', 0o600)
848
- closeSync(handle)
849
- return true
850
- } catch (err) {
851
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
852
- try {
853
- if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS) return false
854
- } catch {
855
- // A vanished/corrupt guard is safe to retry once.
856
- }
857
- rmSync(file, { force: true })
858
- }
859
- }
860
- return false
861
- }
862
-
863
- function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
864
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.claim`)
865
- }
866
-
867
- /**
868
- * A question is stored so a later hook can push it, and it reaches us from a
869
- * shell command, so its size is whatever the agent typed. The push itself is
870
- * bounded by the current 4096-byte push-provider envelopes; this bounds what
871
- * sits on disk in the meantime, and keeps one runaway agent from writing
872
- * megabytes per session.
873
- */
874
- const MAX_STORED_QUESTION_CHARS = 2000
875
-
876
- interface StoredProjectSessionPointer extends ProjectSessionPointer {
877
- updatedAt: number
878
- }
879
-
880
- interface StoredProjectSessionDocument {
881
- root: Record<string, unknown>
882
- sessions: unknown[]
883
- }
884
-
885
- export interface ProjectSessionPointer {
886
- sessionId: string
887
- harness: HookHarness
888
- }
889
-
890
- function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPointer | null {
891
- if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return null
892
- const entry = candidate as {
893
- session_id?: unknown
894
- updated_at?: unknown
895
- harness?: unknown
896
- }
897
- if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
898
- if (typeof entry.updated_at !== 'number') return null
899
- if (!(HOOK_INSTALLABLE_HARNESSES as readonly unknown[]).includes(entry.harness)) return null
900
- return {
901
- sessionId: entry.session_id,
902
- updatedAt: entry.updated_at,
903
- harness: entry.harness as HookHarness,
904
- }
905
- }
906
-
907
- function readStoredProjectSessionDocument(file: string): StoredProjectSessionDocument {
908
- if (!existsSync(file)) return { root: {}, sessions: [] }
909
- try {
910
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
911
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
912
- return { root: {}, sessions: [] }
913
- }
914
- const root = parsed as Record<string, unknown>
915
- return {
916
- root,
917
- sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
918
- }
919
- } catch {
920
- return { root: {}, sessions: [] }
921
- }
922
- }
923
-
924
- /** Records every live session working in a directory, for `notifai ask`. */
925
- export function writeProjectSession(
926
- cwd: string,
927
- env: NodeJS.ProcessEnv,
928
- sessionId: string,
929
- now: number,
930
- harness: HookHarness,
931
- ): void {
932
- const file = projectSessionPointerPath(cwd, env)
933
- withFileLock(`${file}.lock`, () => {
934
- const stored = readStoredProjectSessionDocument(file)
935
- const existing: unknown[] = []
936
- let refreshed: Record<string, unknown> = {}
937
- for (const candidate of stored.sessions) {
938
- const entry = storedProjectSessionPointer(candidate)
939
- if (entry?.sessionId === sessionId) {
940
- refreshed = { ...refreshed, ...(candidate as Record<string, unknown>) }
941
- } else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
942
- existing.push(candidate)
943
- }
944
- }
945
- atomicWriteFileSync(
946
- file,
947
- `${JSON.stringify({
948
- ...stored.root,
949
- sessions: [
950
- ...existing,
951
- { ...refreshed, session_id: sessionId, updated_at: now, harness },
952
- ],
953
- })}\n`,
954
- )
955
- })
956
- }
957
-
958
- function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
959
- return readStoredProjectSessionDocument(file).sessions.flatMap(
960
- (candidate): StoredProjectSessionPointer[] => {
961
- const entry = storedProjectSessionPointer(candidate)
962
- return entry === null ? [] : [entry]
963
- },
964
- )
965
- }
966
-
967
- /**
968
- * Resolves the session working in `cwd`. Stale pointers are ignored rather than
969
- * trusted: an id left by a session that ended days ago would send the question
970
- * into state nothing is watching.
971
- */
972
- export function readProjectSession(
973
- cwd: string,
974
- env: NodeJS.ProcessEnv,
975
- now: number,
976
- maxAgeMs = 24 * 3600 * 1000,
977
- ): string | null {
978
- return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
979
- }
980
-
981
- /** A live pointer keeps when it was last written, so callers can rank by it. */
982
- export interface LiveProjectSessionPointer extends ProjectSessionPointer {
983
- updatedAt: number
984
- }
985
-
986
- /**
987
- * Every session that has fired a hook in this directory recently, newest first.
988
- *
989
- * This is the only local record of which harness sessions are actually alive
990
- * here, which makes it the tiebreaker when the environment alone cannot say
991
- * which harness owns the current shell.
992
- */
993
- export function readLiveProjectSessionPointers(
994
- cwd: string,
995
- env: NodeJS.ProcessEnv,
996
- now: number,
997
- maxAgeMs = 24 * 3600 * 1000,
998
- ): LiveProjectSessionPointer[] {
999
- const file = projectSessionPointerPath(cwd, env)
1000
- return readStoredProjectSessionPointers(file)
1001
- .filter((entry) => now - entry.updatedAt <= maxAgeMs)
1002
- .filter((entry) => {
1003
- try {
1004
- // A pointer is routing evidence only while its session state still
1005
- // exists and parses. SessionEnd and explicit cleanup therefore
1006
- // invalidate it even when a crash prevented removal from this index.
1007
- const sessionFile = sessionStatePath(entry.sessionId, env)
1008
- if (!existsSync(sessionFile)) return false
1009
- const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
1010
- return typeof state === 'object' && state !== null && !Array.isArray(state)
1011
- } catch {
1012
- return false
1013
- }
1014
- })
1015
- .sort((left, right) => right.updatedAt - left.updatedAt)
1016
- .map((entry) => ({
1017
- sessionId: entry.sessionId,
1018
- harness: entry.harness,
1019
- updatedAt: entry.updatedAt,
1020
- }))
1021
- }
1022
-
1023
- export function readProjectSessionPointer(
1024
- cwd: string,
1025
- env: NodeJS.ProcessEnv,
1026
- now: number,
1027
- maxAgeMs = 24 * 3600 * 1000,
1028
- ): ProjectSessionPointer | null {
1029
- const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0]
1030
- return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
1031
- }
1032
-
1033
- export function readMatchingProjectSessionPointer(
1034
- cwd: string,
1035
- env: NodeJS.ProcessEnv,
1036
- now: number,
1037
- sessionId: string,
1038
- harness: HookHarness,
1039
- maxAgeMs = 24 * 3600 * 1000,
1040
- ): ProjectSessionPointer | null {
1041
- const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find(
1042
- (entry) => entry.sessionId === sessionId && entry.harness === harness,
1043
- )
1044
- return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness }
1045
- }
1046
-
1047
- function clearMatchingProjectSession(
1048
- cwd: string,
1049
- env: NodeJS.ProcessEnv,
1050
- sessionId: string,
1051
- ): void {
1052
- const file = projectSessionPointerPath(cwd, env)
1053
- withFileLock(`${file}.lock`, () => {
1054
- const stored = readStoredProjectSessionDocument(file)
1055
- const remaining = stored.sessions.filter((candidate) => {
1056
- const entry = storedProjectSessionPointer(candidate)
1057
- return entry === null || entry.sessionId !== sessionId
1058
- })
1059
- const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions')
1060
- if (remaining.length === 0 && !hasUnknownRootFields) {
1061
- rmSync(file, { force: true })
1062
- return
1063
- }
1064
- atomicWriteFileSync(
1065
- file,
1066
- `${JSON.stringify({
1067
- ...stored.root,
1068
- sessions: remaining,
1069
- })}\n`,
1070
- )
1071
- })
1072
- }
1073
-
1074
- export interface HookContext {
1075
- client: ApiClient
1076
- config: CliConfig
1077
- env: NodeJS.ProcessEnv
1078
- now: () => number
1079
- /** Injected so tests advance a virtual clock instead of sleeping. */
1080
- sleep: (milliseconds: number) => Promise<void>
1081
- /** Bounded wait for the first reply; injected so tests do not sleep. */
1082
- waitForFirstReply: (
1083
- requestId: string,
1084
- timeoutSeconds: number,
1085
- ) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
1086
- /** The active harness selects the native continuation output adapter. */
1087
- harness?: HookHarness
1088
- /**
1089
- * The local record. A hook's decisions are invisible from everywhere else —
1090
- * its stderr belongs to the harness and its usual outcome is to do nothing —
1091
- * so this is the only account of why a question did or did not travel.
1092
- */
1093
- log?: Logger
1094
- }
1095
-
1096
- /**
1097
- * Record one gate decision.
1098
- *
1099
- * `verdict` is what happened to the question and `reason` is the closed-set
1100
- * name of the rule that decided it, so an agent can ask "why was it held" with
1101
- * a filter rather than by matching on prose. The prose still exists — it is what
1102
- * the user sees in the terminal — but it is free to be rewritten, and a log a
1103
- * filter cannot rely on is a log nobody filters.
1104
- */
1105
- export const GATE_REASONS = [
1106
- 'no-session',
1107
- 'no-question',
1108
- 'answered',
1109
- 'continuation-repeat',
1110
- 'continuation-limit',
1111
- 'delivery-limit',
1112
- 'acknowledgement-required',
1113
- 'acknowledgement-abandoned',
1114
- 'harness-cannot-continue',
1115
- 'notifications-off',
1116
- 'claimed-elsewhere',
1117
- 'elapsed',
1118
- 'proceeding',
1119
- ] as const
1120
-
1121
- /**
1122
- * A runtime list rather than a bare type, so the guidance that tells agents to
1123
- * filter on these can be tested against the set the code can actually emit.
1124
- * `already-asked` and `no-devices` were declared here for a long time and never
1125
- * emitted once; documenting them taught agents to filter for silence.
1126
- */
1127
- export type GateReason = (typeof GATE_REASONS)[number]
1128
-
1129
- function gate(
1130
- ctx: HookContext,
1131
- verdict: 'held' | 'proceeding',
1132
- reason: GateReason,
1133
- data: Record<string, unknown> = {},
1134
- ): void {
1135
- ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
1136
- }
1137
-
1138
- export type HookHarness = HookInstallableHarness
1139
-
1140
- /**
1141
- * One round of the waiter's reply poll. Short enough that several questions
1142
- * still share one wall clock, long enough not to hammer the server.
1143
- */
1144
- const REPLY_POLL_SECONDS = 5
1145
-
1146
- /** Avoid hot-looping while a live observer cooperatively yields its claim. */
1147
- const CLAIM_HANDOFF_POLL_MS = 1_000
1148
-
1149
- /**
1150
- * Push a question and block for the answer.
1151
- *
1152
- * An answered question is closed the moment this wait sees the answer: the
1153
- * first device to answer claims the question and the others are told. Until
1154
- * that close, a later reply is a correction — so what this returns as "the"
1155
- * answer is always the latest reply, with the full stream alongside for
1156
- * free-text answers given in parts. An unanswered question stays open,
1157
- * because nobody has acted on the silence yet and the next turn can still
1158
- * collect the answer with `notifai replies`.
1159
- */
1160
- /**
1161
- * Put one registered question on the user's devices. Submission only — the
1162
- * wait happens once, across every live question, in the caller.
1163
- */
1164
- async function prepareQuestionSubmission(
1165
- ctx: HookContext,
1166
- options: {
1167
- title: string
1168
- body: string
1169
- questions: QuestionT[]
1170
- media?: MediaItemT[] | undefined
1171
- project?: string | undefined
1172
- source?: SourceContextT | undefined
1173
- /** How long the server keeps accepting an answer. */
1174
- windowSeconds: number
1175
- /** Absolute owner deadline persisted before submit. */
1176
- ownerDeadlineAt: number
1177
- },
1178
- ): Promise<PendingSubmissionIntent | { error: string }> {
1179
- const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
1180
- // A draft carrying `reply` is rejected outright if it targets a device that
1181
- // cannot answer, so resolve the healthy companion platforms explicitly.
1182
- const answerable = await answerableDevices(ctx)
1183
- if (answerable.length === 0) {
1184
- return { error: 'no device can answer a question yet; leaving this to the terminal' }
1185
- }
1186
- const build = buildDraft(
1187
- ctx.config,
1188
- {
1189
- title: options.title,
1190
- body: options.body,
1191
- lifecycle: { tier: 'needs_you' },
1192
- ...(options.project !== undefined ? { project: options.project } : {}),
1193
- ...(options.media !== undefined ? { media: options.media } : {}),
1194
- device: answerable,
1195
- reply: true,
1196
- replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
1197
- questions: options.questions,
1198
- collapseKey,
1199
- },
1200
- options.source === undefined ? {} : { source: options.source },
1201
- )
1202
- if (!build.ok) return { error: build.error }
1203
-
1204
- return {
1205
- request_id: `req_${randomBytes(18).toString('base64url')}`,
1206
- idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
1207
- collapse_key: collapseKey,
1208
- device_ids: answerable,
1209
- draft: build.draft,
1210
- owner_deadline_at: options.ownerDeadlineAt,
1211
- }
1212
- }
1213
-
1214
- async function submitQuestion(
1215
- ctx: HookContext,
1216
- intent: PendingSubmissionIntent,
1217
- ): Promise<SubmissionReceipt> {
1218
- const receipt = await ctx.client.submit(
1219
- {
1220
- request_id: intent.request_id,
1221
- idempotency_key: intent.idempotency_key,
1222
- draft: intent.draft,
1223
- },
1224
- 0,
1225
- )
1226
- if (receipt.request_id !== intent.request_id) {
1227
- throw new Error(
1228
- `server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`,
1229
- )
1230
- }
1231
- return receipt
1232
- }
1233
-
1234
- function canSubmitCompleteWindow(
1235
- ctx: HookContext,
1236
- intent: PendingSubmissionIntent,
1237
- fallbackWindowSeconds: number,
1238
- ): boolean {
1239
- const frozenWindowSeconds =
1240
- intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds
1241
- return (
1242
- intent.owner_deadline_at - ctx.now() >=
1243
- (frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
1244
- )
1245
- }
1246
-
1247
- /**
1248
- * The waiter's wait: one bounded poll across every live question at once.
1249
- *
1250
- * Each round polls all of them concurrently, so several questions cost the
1251
- * same wall clock as one; the first round that finds any reply returns
1252
- * everything found in that round, and whatever was not answered stays
1253
- * registered. A question whose polling is permanently rejected drops out of
1254
- * the round rather than failing the whole wait.
1255
- */
1256
- async function waitForAnyReply(
1257
- ctx: HookContext,
1258
- requestIds: string[],
1259
- timeoutSeconds: number,
1260
- interruption?: () => 'ownership-ended' | 'new-question' | null,
1261
- ): Promise<{
1262
- byRequest: Map<string, ReplyView[]>
1263
- degraded: boolean
1264
- permanentFailures: Map<string, string>
1265
- interrupted: 'ownership-ended' | 'new-question' | null
1266
- }> {
1267
- const deadline = ctx.now() + timeoutSeconds * 1000
1268
- let degraded = false
1269
- let firstPoll = true
1270
- const permanentFailures = new Map<string, string>()
1271
-
1272
- if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
1273
- return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null }
1274
- }
1275
-
1276
- for (;;) {
1277
- const beforePoll = interruption?.() ?? null
1278
- if (beforePoll !== null) {
1279
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll }
1280
- }
1281
- const remainingMs = Math.max(0, deadline - ctx.now())
1282
- if (!firstPoll && remainingMs === 0) {
1283
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: null }
1284
- }
1285
- firstPoll = false
1286
- const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
1287
- const results = await Promise.all(
1288
- requestIds
1289
- .filter((requestId) => !permanentFailures.has(requestId))
1290
- .map(async (requestId) => {
1291
- try {
1292
- return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
1293
- } catch (err) {
1294
- if (isRetryableReplyPollError(err)) {
1295
- return { requestId, replies: [] as ReplyView[], degraded: true }
1296
- }
1297
- return {
1298
- requestId,
1299
- replies: [] as ReplyView[],
1300
- degraded: false,
1301
- permanentFailure: replyPollFailure(err),
1302
- }
1303
- }
1304
- }),
1305
- )
1306
- const byRequest = new Map<string, ReplyView[]>()
1307
- for (const result of results) {
1308
- degraded ||= result.degraded === true
1309
- if (result.permanentFailure !== undefined) {
1310
- permanentFailures.set(result.requestId, result.permanentFailure)
1311
- }
1312
- if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
1313
- }
1314
- const afterPoll = interruption?.() ?? null
1315
- // SessionEnd or terminal-side retirement wins even if an in-flight poll
1316
- // happened to return an answer. A newly registered question merely asks
1317
- // this owner to yield; an answer already observed remains authoritative.
1318
- if (afterPoll === 'ownership-ended') {
1319
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
1320
- }
1321
- if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
1322
- return { byRequest, degraded, permanentFailures, interrupted: null }
1323
- }
1324
- if (afterPoll === 'new-question') {
1325
- return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll }
1326
- }
1327
- }
1328
- }
1329
-
1330
- /**
1331
- * Local lifecycle signal for a long-lived observer.
1332
- *
1333
- * SessionEnd and terminal-side retirement remove an owned request from pending
1334
- * state; the detached process must stop instead of recreating that session.
1335
- * A new unpushed question asks this owner to yield the session claim so the
1336
- * successor Stop can submit it promptly.
1337
- */
1338
- function waiterInterruption(
1339
- sessionId: string,
1340
- env: NodeJS.ProcessEnv,
1341
- requestIds: readonly string[],
1342
- ): 'ownership-ended' | 'new-question' | null {
1343
- if (sessionHasEnded(sessionId, env)) return 'ownership-ended'
1344
- const pending = pendingList(readSessionState(sessionId, env))
1345
- const owned = new Set(requestIds)
1346
- // One question can be retired from the terminal while this observer owns
1347
- // several. That is a per-question change, not whole-session cancellation;
1348
- // the caller filters the changed set after each poll and keeps the rest.
1349
- return pending.some(
1350
- (entry) =>
1351
- entry.request_id === undefined &&
1352
- (entry.submission === undefined || !owned.has(entry.submission.request_id)),
1353
- )
1354
- ? 'new-question'
1355
- : null
1356
- }
1357
-
1358
- /** A non-retryable replies fault, with enough server identity to act on it. */
1359
- function replyPollFailure(err: unknown): string {
1360
- return err instanceof ApiCallError
1361
- ? `${err.code} (HTTP ${err.status}): ${err.message}`
1362
- : String(err)
1363
- }
1364
-
1365
- function permanentReplyFailureNote(failures: Map<string, string>): string | null {
1366
- if (failures.size === 0) return null
1367
- const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ')
1368
- return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
1369
- }
1370
-
1371
- /** Healthy Companion devices that advertise the current answer job. */
1372
- async function answerableDevices(ctx: HookContext): Promise<string[]> {
1373
- const configured = ctx.config.devices.value
1374
- const { devices } = await ctx.client.listDevices()
1375
- return devices
1376
- .filter(
1377
- (device) =>
1378
- (device.platform === 'ios' ||
1379
- device.platform === 'macos' ||
1380
- device.platform === 'android') &&
1381
- device.registration_healthy &&
1382
- device.capabilities?.includes('answer') === true &&
1383
- device.derived_status !== 'must_update',
1384
- )
1385
- .filter((device) => configured === null || configured.includes(device.device_id))
1386
- .map((device) => device.device_id)
1387
- }
1388
-
1389
- /**
1390
- * Everything retirement needs. Narrower than a HookContext on purpose:
1391
- * `notifai ask` supersedes the previous question and it is a plain command with
1392
- * no hook payload, no idle probe and nothing to sleep for.
1393
- */
1394
- export type RetireDeps = Pick<HookContext, 'client' | 'config'> & Pick<Partial<HookContext>, 'log'>
1395
-
1396
- /**
1397
- * Close is a server-side serialization fence: a successful response contains
1398
- * every reply that committed before the window closed. `null` is deliberately
1399
- * different from an empty reply set — it means ownership could not be proven
1400
- * final and the local record must stay recoverable.
1401
- */
1402
- async function finalizeReplies(
1403
- ctx: RetireDeps,
1404
- requestId: string,
1405
- ): Promise<ListRepliesResponse | null> {
1406
- try {
1407
- return await ctx.client.closeReplies(requestId)
1408
- } catch {
1409
- return null
1410
- }
1411
- }
1412
-
1413
- /** Best effort only for already-journaled retirement debt. */
1414
- async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
1415
- await finalizeReplies(ctx, requestId)
1416
- }
1417
-
1418
- /**
1419
- * Close the reply window so the server can mint the retirement push. Client
1420
- * submissions cannot carry `question_retired`; that event is reserved for
1421
- * server-origin state syncs.
1422
- */
1423
- async function proveRetirement(
1424
- ctx: RetireDeps,
1425
- requestId: string,
1426
- ): Promise<boolean> {
1427
- const response = await finalizeReplies(ctx, requestId)
1428
- const proven = response !== null
1429
- ctx.log?.info('hook.retirement', {
1430
- request_id: requestId,
1431
- attempted: true,
1432
- proven,
1433
- })
1434
- return proven
1435
- }
1436
-
1437
- /**
1438
- * Park a delivered question for retirement. Called at the moment we learn it is
1439
- * dead, which is frequently not a moment we can reach the network.
1440
- *
1441
- * Nothing is parked for a question that never reached a device: with no
1442
- * request_id there is nothing on any device to retire.
1443
- */
1444
- export function parkForRetirement(
1445
- sessionId: string,
1446
- env: NodeJS.ProcessEnv,
1447
- pending: PendingQuestion,
1448
- state: LifecycleEndState,
1449
- ): void {
1450
- const retirement = retiringQuestion(pending, state)
1451
- if (retirement === null) return
1452
- updateSessionState(sessionId, env, (current) => {
1453
- const already = current.retiring ?? []
1454
- const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
1455
- if (existing < 0) return { ...current, retiring: [...already, retirement] }
1456
- const retiring = [...already]
1457
- // An observed answer is final truth and upgrades an earlier supersession
1458
- // parked while the submission callback was racing a newer question.
1459
- if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
1460
- retiring[existing] = retirement
1461
- }
1462
- return { ...current, retiring }
1463
- })
1464
- }
1465
-
1466
- /**
1467
- * Convert live question state into a complete retirement instruction.
1468
- *
1469
- * A request/collapse pair without the original Delivery targets is not safe to
1470
- * send: re-resolving today's routing can miss a device that still shows the
1471
- * question or retire a device that never received it. Fail explicitly and keep
1472
- * the pending record instead of silently broadening or narrowing the fanout.
1473
- */
1474
- function retiringQuestion(
1475
- pending: PendingQuestion,
1476
- state: LifecycleEndState,
1477
- cwd?: string,
1478
- ): RetiringQuestion | null {
1479
- // A submit journal is written before the first network byte. SessionEnd can
1480
- // therefore race a request that the server accepts after local cleanup. Its
1481
- // reserved request id and exact fanout are already sufficient retirement
1482
- // identity, even though the ordinary live fields have not been promoted yet.
1483
- const requestId = pending.request_id ?? pending.submission?.request_id
1484
- const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key
1485
- const deviceIds = pending.device_ids ?? pending.submission?.device_ids
1486
- const hasRequest = requestId !== undefined
1487
- const hasCollapse = collapseKey !== undefined
1488
- const hasDevices = deviceIds !== undefined && deviceIds.length > 0
1489
- if (!hasRequest && !hasCollapse && !hasDevices) return null
1490
- if (!hasRequest || !hasCollapse || !hasDevices) {
1491
- throw new Error(
1492
- 'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
1493
- )
1494
- }
1495
- const source = sourceContextAtHookEvent(pending.source, cwd)
1496
- return {
1497
- request_id: requestId!,
1498
- collapse_key: collapseKey!,
1499
- device_ids: [...deviceIds!],
1500
- question: pending.question,
1501
- ...(pending.project !== undefined ? { project: pending.project } : {}),
1502
- ...(source !== undefined ? { source } : {}),
1503
- state,
1504
- }
1505
- }
1506
-
1507
- const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay'])
1508
-
1509
- function normalizePrompt(value: string): string {
1510
- return value.trim().replace(/\s+/g, ' ').toLowerCase()
1511
- }
1512
-
1513
- function pendingChoiceLabels(pending: PendingQuestion): string[] {
1514
- const fromQuestions = (pending.questions ?? []).flatMap((question) =>
1515
- (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]),
1516
- )
1517
- return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0)
1518
- }
1519
-
1520
- function pendingHasMatchingChoice(pending: PendingQuestion, normalizedPrompt: string): boolean {
1521
- return pendingChoiceLabels(pending).includes(normalizedPrompt)
1522
- }
1523
-
1524
- /**
1525
- * Which outstanding questions this prompt plausibly answers.
1526
- *
1527
- * A unique closed-choice match is enough. A short confirmation such as "done"
1528
- * matches only when exactly one question is outstanding. Long unrelated
1529
- * prompts leave every question open for the reply window.
1530
- */
1531
- export function pendingAnsweredByPrompt(
1532
- prompt: string | undefined,
1533
- pending: readonly PendingQuestion[],
1534
- ): PendingQuestion[] {
1535
- const normalized = normalizePrompt(prompt ?? '')
1536
- if (normalized.length === 0 || pending.length === 0) return []
1537
-
1538
- const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized))
1539
- if (labelled.length === 1) return labelled
1540
- if (labelled.length > 1) return []
1541
- if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized)) return [...pending]
1542
- return []
1543
- }
1544
-
1545
- /**
1546
- * Send every parked retirement, and forget the ones that landed. A failure
1547
- * leaves the entry in place for the next hook rather than losing it, which is
1548
- * the whole reason the queue exists.
1549
- */
1550
- export async function drainRetirements(
1551
- ctx: RetireDeps,
1552
- sessionId: string,
1553
- env: NodeJS.ProcessEnv,
1554
- ): Promise<string[]> {
1555
- const queue = readSessionState(sessionId, env).retiring ?? []
1556
- if (queue.length === 0) return []
1557
-
1558
- const retired: string[] = []
1559
- for (const entry of queue) {
1560
- const proven = await proveRetirement(ctx, entry.request_id)
1561
- if (proven) {
1562
- retired.push(entry.request_id)
1563
- // Persist each success before touching the next entry. A later corrupt
1564
- // record or interrupted process must not resurrect work already done.
1565
- updateSessionState(sessionId, env, (current) => ({
1566
- ...current,
1567
- retiring: (current.retiring ?? []).filter(
1568
- (candidate) => candidate.request_id !== entry.request_id,
1569
- ),
1570
- }))
1571
- }
1572
- }
1573
- return retired
1574
- }
1575
-
1576
- function orphanQueuePath(env: NodeJS.ProcessEnv): string {
1577
- return path.join(stateDir(env), 'retire-queue.json')
1578
- }
1579
-
1580
- function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
1581
- const file = orphanQueuePath(env)
1582
- if (!existsSync(file)) return []
1583
- try {
1584
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
1585
- return Array.isArray(parsed) ? (parsed as OrphanRetirement[]) : []
1586
- } catch {
1587
- // Same stance as session state: corruption fails closed to "nothing queued".
1588
- return []
1589
- }
1590
- }
1591
-
1592
- function writeOrphanQueueUnlocked(file: string, queue: OrphanRetirement[]): void {
1593
- atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`)
1594
- }
1595
-
1596
- function updateOrphanQueue(
1597
- env: NodeJS.ProcessEnv,
1598
- update: (current: OrphanRetirement[]) => OrphanRetirement[],
1599
- ): OrphanRetirement[] {
1600
- const file = orphanQueuePath(env)
1601
- return withFileLock(`${file}.lock`, () => {
1602
- const next = update(readOrphanQueue(env))
1603
- writeOrphanQueueUnlocked(file, next)
1604
- return next
1605
- })
1606
- }
1607
-
1608
- /** Move retirements into the global queue; deduped so a retry costs nothing. */
1609
- export function orphanRetirements(
1610
- env: NodeJS.ProcessEnv,
1611
- entries: RetiringQuestion[],
1612
- now: number,
1613
- ): void {
1614
- if (entries.length === 0) return
1615
- updateOrphanQueue(env, (queue) => {
1616
- const known = new Set(queue.map((entry) => entry.request_id))
1617
- const added = entries
1618
- .filter((entry) => !known.has(entry.request_id))
1619
- .map((entry) => ({ ...entry, enqueued_at: now }))
1620
- return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
1621
- })
1622
- }
1623
-
1624
- /**
1625
- * Retire everything a dead session left behind. Failures stay queued for the
1626
- * next holder of a client; entries past the TTL are dropped as already dead.
1627
- */
1628
- export async function drainOrphanRetirements(
1629
- ctx: RetireDeps,
1630
- env: NodeJS.ProcessEnv,
1631
- now: number,
1632
- ): Promise<string[]> {
1633
- const queue = readOrphanQueue(env)
1634
- if (queue.length === 0) return []
1635
-
1636
- const done: string[] = []
1637
- for (const entry of queue) {
1638
- const age = now - entry.enqueued_at
1639
- // A negative age is a clock jump, not a fresh entry; retiring is idempotent
1640
- // and cheap, so treat it as due rather than letting it linger for ever.
1641
- if (age > ORPHAN_TTL_MS) {
1642
- done.push(entry.request_id)
1643
- updateOrphanQueue(env, (current) =>
1644
- current.filter((candidate) => candidate.request_id !== entry.request_id),
1645
- )
1646
- continue
1647
- }
1648
- const proven = await proveRetirement(ctx, entry.request_id)
1649
- if (proven) {
1650
- done.push(entry.request_id)
1651
- updateOrphanQueue(env, (current) =>
1652
- current.filter((candidate) => candidate.request_id !== entry.request_id),
1653
- )
1654
- }
1655
- }
1656
- return done
1657
- }
1658
-
1659
- const LATE_STOP_POLL_SECONDS = 4
1660
- const LATE_PROMPT_POLL_SECONDS = 3
1661
- /**
1662
- * The one bound on answer deliveries, for every route.
1663
- *
1664
- * It caps two things that both used to be able to run away: how many times a
1665
- * single accepted answer may be handed to a route before the journal gives up
1666
- * on it, and how many answer-driven continuation generations one chain may
1667
- * produce.
1668
- */
1669
- export const MAX_CONTINUATION_COUNT = 3
1670
-
1671
- /**
1672
- * How many turns an answer may be *held* before it is settled unread.
1673
- *
1674
- * A hold is not a delivery: the route reported that it handed nothing over, so
1675
- * the agent has not seen the answer and the wake-loop the continuation cap
1676
- * exists to stop has not happened. Counting holds against that cap meant three
1677
- * turns where a liveness probe could not reach a busy session were enough to
1678
- * discard an answer the user had already given.
1679
- *
1680
- * It still has to end. A route that can never deliver would otherwise retry
1681
- * every turn for the life of the session, so holds get their own, far looser
1682
- * bound — loose because each one costs nothing and the answer is still wanted.
1683
- */
1684
- export const MAX_HELD_DELIVERIES = 20
1685
-
1686
- interface PendingPoll {
1687
- /** The answer to act on: the latest reply, because a later one corrects. */
1688
- reply: ReplyView | null
1689
- /** Every reply in arrival order, for free-text answers given in parts. */
1690
- replies: ReplyView[]
1691
- degraded: boolean
1692
- permanentFailure: string | null
1693
- }
1694
-
1695
- /** One bounded recovery poll for a question that outlived its original wait. */
1696
- async function pollPendingReply(
1697
- ctx: HookContext,
1698
- pending: PendingQuestion,
1699
- timeoutSeconds: number,
1700
- ): Promise<PendingPoll> {
1701
- if (pending.request_id === undefined) {
1702
- return { reply: null, replies: [], degraded: false, permanentFailure: null }
1703
- }
1704
- try {
1705
- const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
1706
- return {
1707
- reply: result.replies.at(-1) ?? null,
1708
- replies: result.replies,
1709
- degraded: result.degraded === true,
1710
- permanentFailure: null,
1711
- }
1712
- } catch (err) {
1713
- return isRetryableReplyPollError(err)
1714
- ? { reply: null, replies: [], degraded: true, permanentFailure: null }
1715
- : { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) }
1716
- }
1717
- }
1718
-
1719
- /** One answered registered question, with everything the agent needs to read it. */
1720
- interface AnsweredPending {
1721
- pending: PendingQuestion
1722
- reply: ReplyView
1723
- replies: ReplyView[]
1724
- /** Immutable server snapshot for this request, known after a replies/close response. */
1725
- agent_acknowledgement_required?: boolean | undefined
1726
- /** Whether that acknowledgement must carry text; the account's snapshot. */
1727
- agent_acknowledgement_text_required?: boolean | undefined
1728
- }
1729
-
1730
- interface FinalizedPending {
1731
- pending: PendingQuestion
1732
- response: ListRepliesResponse | null
1733
- }
1734
-
1735
- /** Close several windows concurrently without confusing failure with silence. */
1736
- async function finalizePendings(
1737
- ctx: HookContext,
1738
- pending: PendingQuestion[],
1739
- ): Promise<FinalizedPending[]> {
1740
- return Promise.all(
1741
- pending.map(async (entry) => {
1742
- const response = await finalizeReplies(ctx, entry.request_id!)
1743
- ctx.log?.info('hook.retirement', {
1744
- request_id: entry.request_id,
1745
- attempted: true,
1746
- proven: response !== null,
1747
- })
1748
- return { pending: entry, response }
1749
- }),
1750
- )
1751
- }
1752
-
1753
- /** Convert a fenced final reply set into the exact answer handed to the model. */
1754
- function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1755
- const replies = finalized.response?.replies ?? []
1756
- const reply = replies.at(-1)
1757
- return reply === undefined
1758
- ? null
1759
- : {
1760
- pending: finalized.pending,
1761
- reply,
1762
- replies,
1763
- agent_acknowledgement_required:
1764
- finalized.response?.agent_acknowledgement_required,
1765
- agent_acknowledgement_text_required:
1766
- finalized.response?.agent_acknowledgement_text_required,
1767
- }
1768
- }
1769
-
1770
- /** Persist and surface one answer without letting the two representations drift. */
1771
- function reportAnswer(
1772
- ctx: HookContext,
1773
- notes: string[],
1774
- answered: AnsweredPending,
1775
- late: boolean,
1776
- ): void {
1777
- ctx.log?.info('hook.answer', {
1778
- answered: true,
1779
- stage: 'queued',
1780
- ...(late ? { late: true } : {}),
1781
- request_id: answered.pending.request_id,
1782
- device: answered.reply.device_name,
1783
- text_chars: answered.reply.text.length,
1784
- })
1785
- notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
1786
- }
1787
-
1788
- /**
1789
- * One bounded recovery poll across every delivered question, concurrently —
1790
- * N questions must not multiply the hook's latency budget by N.
1791
- */
1792
- async function pollPendingReplies(
1793
- ctx: HookContext,
1794
- live: PendingQuestion[],
1795
- timeoutSeconds: number,
1796
- ): Promise<{
1797
- answered: AnsweredPending[]
1798
- transientTrouble: boolean
1799
- permanentFailures: Map<string, string>
1800
- }> {
1801
- const polls = await Promise.all(
1802
- live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
1803
- )
1804
- const answered: AnsweredPending[] = []
1805
- let transientTrouble = false
1806
- const permanentFailures = new Map<string, string>()
1807
- for (const [index, poll] of polls.entries()) {
1808
- if (poll.reply !== null) {
1809
- answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
1810
- }
1811
- if (poll.degraded) transientTrouble = true
1812
- if (poll.permanentFailure !== null) {
1813
- permanentFailures.set(live[index]!.request_id!, poll.permanentFailure)
1814
- }
1815
- }
1816
- return { answered, transientTrouble, permanentFailures }
1817
- }
1818
-
1819
- /** The registered-question queue. Anything but the current shape reads as empty. */
1820
- function pendingList(state: SessionState): PendingQuestion[] {
1821
- return Array.isArray(state.pending) ? state.pending : []
1822
- }
1823
-
1824
- /** Registration identity — the convention every racing writer compares by. */
1825
- function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1826
- if (a.question_id !== undefined || b.question_id !== undefined) {
1827
- return a.question_id !== undefined && a.question_id === b.question_id
1828
- }
1829
- return a.question === b.question && a.asked_at === b.asked_at
1830
- }
1831
-
1832
- /**
1833
- * The frozen draft itself will never be accepted. Replaying it forever hides a
1834
- * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
1835
- * Auth, conflicts, and timeouts are not this class.
1836
- */
1837
- function isTerminalDraftRejection(err: ApiCallError): boolean {
1838
- return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
1839
- }
1840
-
1841
- export function dropPendingQuestion(
1842
- sessionId: string,
1843
- env: NodeJS.ProcessEnv,
1844
- entry: PendingQuestion,
1845
- ): void {
1846
- updateSessionState(sessionId, env, (current) => {
1847
- const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
1848
- const next: SessionState = { ...current }
1849
- if (pending.length > 0) next.pending = pending
1850
- else delete next.pending
1851
- return next
1852
- })
1853
- }
1854
-
1855
- /**
1856
- * Drop registrations that never reached a device, so a later Stop cannot push
1857
- * them. A retirement push would be noise: there is nothing on any device to
1858
- * withdraw. Returns the withdrawn entries in registration order.
1859
- */
1860
- export function withdrawUnpushedQuestions(
1861
- sessionId: string,
1862
- env: NodeJS.ProcessEnv,
1863
- ): PendingQuestion[] {
1864
- let withdrawn: PendingQuestion[] = []
1865
- updateSessionState(sessionId, env, (current) => {
1866
- const pending = pendingList(current)
1867
- withdrawn = pending.filter((entry) => entry.request_id === undefined)
1868
- if (withdrawn.length === 0) return current
1869
- const remaining = pending.filter((entry) => entry.request_id !== undefined)
1870
- const next: SessionState = { ...current }
1871
- if (remaining.length > 0) next.pending = remaining
1872
- else delete next.pending
1873
- return next
1874
- })
1875
- return withdrawn
1876
- }
1877
-
1878
- function clearFrozenSubmission(
1879
- sessionId: string,
1880
- env: NodeJS.ProcessEnv,
1881
- entry: PendingQuestion,
1882
- ): void {
1883
- updateSessionState(sessionId, env, (current) => {
1884
- const list = pendingList(current)
1885
- const index = list.findIndex((candidate) => isSamePending(candidate, entry))
1886
- if (index < 0) return current
1887
- const next = [...list]
1888
- const copy = { ...next[index]! }
1889
- delete copy.submission
1890
- next[index] = copy
1891
- return { ...current, pending: next }
1892
- })
1893
- }
1894
-
1895
- /** The question set this pending record pushes, however it was registered. */
1896
- function pendingQuestions(pending: PendingQuestion): QuestionT[] {
1897
- return pending.questions ?? [{ id: 'q1', text: pending.question }]
1898
- }
1899
-
1900
- /** Did any question in the set offer choices? Decides how replies combine. */
1901
- function pendingHasChoices(pending: PendingQuestion): boolean {
1902
- return pendingQuestions(pending).some((question) => question.choices !== undefined)
1903
- }
1904
-
1905
- /**
1906
- * The answer as the agent should read it. For a question with choices the
1907
- * latest reply IS the answer — an earlier conflicting one was corrected by
1908
- * it. Free-text answers can arrive in parts, and every part reaches the
1909
- * agent in the order it was written, so it can tell expansion from
1910
- * correction itself.
1911
- *
1912
- * The continuation repeats only identity the agent actually registered and
1913
- * the answer the server accepted. It never invents trust, urgency, permission,
1914
- * or approval claims for the transport to assert.
1915
- */
1916
- function answerContext(answered: AnsweredPending): string {
1917
- const { pending, replies } = answered
1918
- const latest = replies.at(-1)
1919
- if (latest === undefined) return 'Notifai — no answer was recorded.'
1920
- const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))]
1921
- const identity =
1922
- answeredQuestionIds.length === 0
1923
- ? ''
1924
- : `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `
1925
- const question = `question ${JSON.stringify(pending.question)}`
1926
- if (replies.length === 1 || pendingHasChoices(pending)) {
1927
- return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`
1928
- }
1929
- const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ')
1930
- return (
1931
- `Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
1932
- `in the order written: ${parts}. Later parts extend or correct earlier ones.`
1933
- )
1934
- }
1935
-
1936
- /**
1937
- * Every answer that has arrived, as one message. Several registered questions
1938
- * may resolve in one hook pass; the agent reads them together, each answer
1939
- * tied to the question that asked it, with a truthful note about anything
1940
- * still waiting.
1941
- */
1942
- function acknowledgementCommand(requestId: string, textRequired = true): string {
1943
- return textRequired
1944
- ? `notifai acknowledge ${requestId} --text <text>`
1945
- : `notifai acknowledge ${requestId}`
1946
- }
1947
-
1948
- /**
1949
- * The acknowledgement is owed either way; only its text is conditional. So the
1950
- * instruction never says "you may skip this" — it says what to run.
1951
- */
1952
- function acknowledgementDemand(textRequired: boolean): string {
1953
- return textRequired
1954
- ? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
1955
- : ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words'
1956
- }
1957
-
1958
- function acknowledgementContext(answered: AnsweredPending[]): string {
1959
- const due = answered.filter(
1960
- (entry) =>
1961
- entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined,
1962
- )
1963
- if (due.length === 0) {
1964
- return ' Agent Acknowledgement is not required for the answered request(s).'
1965
- }
1966
- if (due.length === 1) {
1967
- const entry = due[0]!
1968
- const requestId = entry.pending.request_id!
1969
- const textRequired = entry.agent_acknowledgement_text_required !== false
1970
- return (
1971
- ` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
1972
- `\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`
1973
- )
1974
- }
1975
- const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false)
1976
- const commands = due
1977
- .map((entry) => {
1978
- const requestId = entry.pending.request_id!
1979
- return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``
1980
- })
1981
- .join('\n')
1982
- return (
1983
- ` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below${acknowledgementDemand(anyTextRequired)}:\n` +
1984
- commands
1985
- )
1986
- }
1987
-
1988
- function answersContext(answered: AnsweredPending[], remaining: number): string {
1989
- const tail =
1990
- remaining > 0
1991
- ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1992
- : ''
1993
- const guidance = acknowledgementContext(answered)
1994
- if (answered.length === 1) {
1995
- return answerContext(answered[0]!) + tail + guidance
1996
- }
1997
- const lines = answered.map(({ pending, replies }) => {
1998
- const latest = replies.at(-1)!
1999
- const answer =
2000
- replies.length === 1 || pendingHasChoices(pending)
2001
- ? JSON.stringify(latest.text)
2002
- : `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
2003
- const ids = [...new Set(latest.answers.map((entry) => entry.question_id))]
2004
- const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
2005
- return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
2006
- })
2007
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`
2008
- }
2009
-
2010
- function stopAnswerOutput(context: string): string {
2011
- return JSON.stringify({
2012
- decision: 'block',
2013
- reason: context,
2014
- })
2015
- }
2016
-
2017
- /**
2018
- * Close, truthfully retire, and forget every answered pending question in one
2019
- * state write, without dropping any other retirement debt or any question
2020
- * still waiting. Stop answers also open one bounded continuation generation;
2021
- * UserPromptSubmit answers ride the user's new turn.
2022
- */
2023
- function stageAcceptedAnswers(
2024
- ctx: HookContext,
2025
- sessionId: string,
2026
- answered: AnsweredPending[],
2027
- remaining: number,
2028
- ): AcceptedAnswerDelivery {
2029
- const accepted: AcceptedAnswerDelivery = {
2030
- answers: answered,
2031
- remaining,
2032
- recorded_at: ctx.now(),
2033
- }
2034
- updateSessionState(sessionId, ctx.env, (current) => {
2035
- const pendingRemaining = pendingList(current).filter(
2036
- (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
2037
- )
2038
- const acknowledgementDue = [...(current.acknowledgement_due ?? [])]
2039
- for (const answer of answered) {
2040
- const requestId = answer.pending.request_id
2041
- if (
2042
- answer.agent_acknowledgement_required === true &&
2043
- requestId !== undefined &&
2044
- !acknowledgementDue.some((entry) => entry.request_id === requestId)
2045
- ) {
2046
- acknowledgementDue.push({
2047
- request_id: requestId,
2048
- recorded_at: accepted.recorded_at,
2049
- text_required: answer.agent_acknowledgement_text_required !== false,
2050
- })
2051
- }
2052
- }
2053
- const next: SessionState = {
2054
- ...current,
2055
- accepted,
2056
- }
2057
- if (pendingRemaining.length > 0) next.pending = pendingRemaining
2058
- else delete next.pending
2059
- if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
2060
- else delete next.acknowledgement_due
2061
- return next
2062
- })
2063
- return accepted
2064
- }
2065
-
2066
- /** Amend the live accepted record in place, without resurrecting a settled one. */
2067
- function amendAcceptedAnswers(
2068
- ctx: HookContext,
2069
- sessionId: string,
2070
- amend: (accepted: AcceptedAnswerDelivery) => AcceptedAnswerDelivery,
2071
- ): void {
2072
- updateSessionState(sessionId, ctx.env, (current) =>
2073
- current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) },
2074
- )
2075
- }
2076
-
2077
- /**
2078
- * Finish a route write that linearized before SessionEnd.
2079
- *
2080
- * Ordinary state writers stop at the ended marker. This one narrow completion
2081
- * is allowed through because the journal's own `delivery_committed_at` proves
2082
- * the irreversible write won the ordering first; without the matching finish,
2083
- * a later SessionStart would replay a write that already reached the harness.
2084
- */
2085
- function finishCommittedDelivery(
2086
- ctx: HookContext,
2087
- sessionId: string,
2088
- accepted: AcceptedAnswerDelivery,
2089
- deliveredRoute: string,
2090
- ): void {
2091
- const file = sessionStatePath(sessionId, ctx.env)
2092
- withFileLock(`${file}.lock`, () => {
2093
- const current = readSessionState(sessionId, ctx.env)
2094
- if (
2095
- current.accepted?.recorded_at !== accepted.recorded_at ||
2096
- current.accepted.delivery_committed_at === undefined
2097
- ) {
2098
- return
2099
- }
2100
- writeSessionStateUnlocked(file, sessionId, {
2101
- ...current,
2102
- accepted: {
2103
- ...current.accepted,
2104
- delivered_at: ctx.now(),
2105
- delivered_route: deliveredRoute,
2106
- },
2107
- })
2108
- })
2109
- }
2110
-
2111
- export function clearAcknowledgementObligation(
2112
- sessionId: string,
2113
- env: NodeJS.ProcessEnv,
2114
- requestId: string,
2115
- ): boolean {
2116
- let cleared = false
2117
- updateSessionState(sessionId, env, (current) => {
2118
- const due = current.acknowledgement_due ?? []
2119
- const remaining = due.filter((entry) => entry.request_id !== requestId)
2120
- cleared = remaining.length !== due.length
2121
- if (!cleared) return current
2122
- const next = { ...current }
2123
- if (remaining.length > 0) next.acknowledgement_due = remaining
2124
- else delete next.acknowledgement_due
2125
- return next
2126
- })
2127
- return cleared
2128
- }
2129
-
2130
- function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
2131
- const commands = due
2132
- .map(
2133
- (entry) =>
2134
- `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``,
2135
- )
2136
- .join('\n')
2137
- const anyTextRequired = due.some((entry) => entry.text_required !== false)
2138
- return (
2139
- `Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
2140
- `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`
2141
- )
2142
- }
2143
-
2144
- /**
2145
- * How many turns in a row a session may be held waiting for an acknowledgement.
2146
- *
2147
- * The gate is the one place hooks deliberately break the fail-open rule, and it
2148
- * had no bound at all: an agent that could not or would not acknowledge — or a
2149
- * server that stayed unreachable, since an error here counts as unresolved —
2150
- * held every turn of that session for ever. Blocking a user's agent
2151
- * indefinitely does not get them an acknowledgement; it costs them the agent as
2152
- * well as the acknowledgement.
2153
- */
2154
- const MAX_ACKNOWLEDGEMENT_BLOCKS = 3
2155
-
2156
- /**
2157
- * Hold the turn for an outstanding acknowledgement, or give up and let it
2158
- * through once holding has stopped being worth its cost.
2159
- *
2160
- * Returns the outcome that blocks the turn, or `null` to carry on.
2161
- */
2162
- function holdForAcknowledgement(
2163
- ctx: HookContext,
2164
- sessionId: string,
2165
- due: readonly AcknowledgementDue[],
2166
- notes: string[],
2167
- ): HookOutcome | null {
2168
- if (due.length === 0) return null
2169
- const requestIds = due.map((entry) => entry.request_id)
2170
- const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1
2171
- if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
2172
- // Drop the obligation with the reason recorded. The answer was already
2173
- // delivered; what is lost is the agent's receipt for it, and the log is
2174
- // where that loss stays visible.
2175
- for (const requestId of requestIds) clearAcknowledgementObligation(sessionId, ctx.env, requestId)
2176
- resetAcknowledgementBlocks(sessionId, ctx.env)
2177
- gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
2178
- request_ids: requestIds,
2179
- blocks: blocks - 1,
2180
- limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
2181
- })
2182
- notes.push(
2183
- `no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`,
2184
- )
2185
- return null
2186
- }
2187
- updateSessionState(sessionId, ctx.env, (current) => ({
2188
- ...current,
2189
- acknowledgement_blocks: blocks,
2190
- }))
2191
- gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks })
2192
- return {
2193
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
2194
- notes,
2195
- log: { stage: 'acknowledgement-required', request_ids: requestIds },
2196
- }
2197
- }
2198
-
2199
- /** A turn that was not held resets the streak; only consecutive holds count. */
2200
- function resetAcknowledgementBlocks(sessionId: string, env: NodeJS.ProcessEnv): void {
2201
- updateSessionState(sessionId, env, (current) => {
2202
- if (current.acknowledgement_blocks === undefined) return current
2203
- const next = { ...current }
2204
- delete next.acknowledgement_blocks
2205
- return next
2206
- })
2207
- }
2208
-
2209
- async function reconcileAcknowledgementObligations(
2210
- ctx: HookContext,
2211
- sessionId: string,
2212
- due: readonly AcknowledgementDue[],
2213
- ): Promise<AcknowledgementDue[]> {
2214
- const unresolved: AcknowledgementDue[] = []
2215
- for (const obligation of due) {
2216
- try {
2217
- const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
2218
- waitSeconds: 0,
2219
- })
2220
- if (
2221
- snapshot.agent_acknowledgement_required === false ||
2222
- snapshot.agent_acknowledgement !== null
2223
- ) {
2224
- clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id)
2225
- } else {
2226
- unresolved.push(obligation)
2227
- }
2228
- } catch (err) {
2229
- unresolved.push(obligation)
2230
- ctx.log?.error('hook.gate', {
2231
- verdict: 'held',
2232
- reason: 'acknowledgement-required',
2233
- stage: 'reconcile-failed',
2234
- request_id: obligation.request_id,
2235
- message: err instanceof Error ? err.message : String(err),
2236
- })
2237
- }
2238
- }
2239
- return unresolved
2240
- }
2241
-
2242
- /**
2243
- * Retire the answered questions and close the journal.
2244
- *
2245
- * Called once an answer's delivery is acknowledged — by the route's own write,
2246
- * or by the successor Stop of a blocking continuation. The continuation counter
2247
- * grows with each settled generation, so the cap that bounds chained
2248
- * answer-to-question loops is a real count rather than a constant 1.
2249
- */
2250
- function settleAcceptedAnswers(
2251
- ctx: HookContext,
2252
- sessionId: string,
2253
- accepted: AcceptedAnswerDelivery,
2254
- cwd?: string,
2255
- ): void {
2256
- const retirements = accepted.answers
2257
- .map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
2258
- .filter((entry): entry is RetiringQuestion => entry !== null)
2259
- updateSessionState(sessionId, ctx.env, (current) => {
2260
- const retiring = [...(current.retiring ?? [])]
2261
- for (const retirement of retirements) {
2262
- const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
2263
- if (existing < 0) retiring.push(retirement)
2264
- else retiring[existing] = retirement
2265
- }
2266
- const next: SessionState = { ...current, retiring }
2267
- delete next.accepted
2268
- next.continuation = {
2269
- answered_at: accepted.recorded_at,
2270
- count: (current.continuation?.count ?? 0) + 1,
2271
- }
2272
- return next
2273
- })
2274
- }
2275
-
2276
- async function retirePendings(
2277
- ctx: HookContext,
2278
- envelope: HookEnvelope,
2279
- sessionId: string,
2280
- entries: PendingQuestion[],
2281
- state: LifecycleEndState,
2282
- ): Promise<void> {
2283
- updateSessionState(sessionId, ctx.env, (current) => {
2284
- const retiring = [...(current.retiring ?? [])]
2285
- for (const entry of entries) {
2286
- const retirement = retiringQuestion(entry, state, envelope.cwd)
2287
- if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
2288
- retiring.push(retirement)
2289
- }
2290
- }
2291
- const pending = pendingList(current).filter(
2292
- (candidate) => !entries.some((entry) => isSamePending(candidate, entry)),
2293
- )
2294
- const next: SessionState = { ...current }
2295
- if (pending.length > 0) next.pending = pending
2296
- else delete next.pending
2297
- if (retiring.length > 0) next.retiring = retiring
2298
- else delete next.retiring
2299
- return next
2300
- })
2301
- // Network retirement is deliberately deferred to a later no-question hook.
2302
- // The state write above is the durable operation; blocking here creates a
2303
- // crash window before an answer can reach harness stdout.
2304
- }
2305
-
2306
- // ---------------------------------------------------------------------------
2307
- // UserPromptSubmit — the user is at the keyboard
2308
- // ---------------------------------------------------------------------------
2309
-
2310
- /**
2311
- * Records presence. Retires a live question only when this prompt plausibly
2312
- * answers it. Typing in the terminal is not by itself a resolution: the User
2313
- * may be answering a different question on a device.
2314
- */
2315
- export async function handleUserPromptSubmit(
2316
- ctx: HookContext,
2317
- envelope: HookEnvelope,
2318
- ): Promise<HookOutcome> {
2319
- const notes: string[] = []
2320
- const sessionId = envelope.session_id
2321
- if (!sessionId) return { notes }
2322
-
2323
- const state = readSessionState(sessionId, ctx.env)
2324
- if (state.accepted !== undefined) {
2325
- updateSessionState(sessionId, ctx.env, (current) => ({
2326
- ...current,
2327
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2328
- last_prompt_at: ctx.now(),
2329
- }))
2330
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
2331
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
2332
- }
2333
- notes.push(
2334
- state.accepted.delivered_at === undefined
2335
- ? 'a device answer is safely journaled; the next Stop will deliver it'
2336
- : 'a device answer has already been delivered; the next Stop will close it out',
2337
- )
2338
- return { notes }
2339
- }
2340
- const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
2341
- let lateAnswers: AnsweredPending[] = []
2342
- if (live.length > 0) {
2343
- const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(
2344
- ctx,
2345
- live,
2346
- LATE_PROMPT_POLL_SECONDS,
2347
- )
2348
- const finalized = await finalizePendings(
2349
- ctx,
2350
- answered.map((entry) => entry.pending),
2351
- )
2352
- lateAnswers = answered.map((entry) => {
2353
- const response = finalized.find((candidate) =>
2354
- isSamePending(candidate.pending, entry.pending),
2355
- )?.response
2356
- const replies = response?.replies.length ? response.replies : entry.replies
2357
- if (response === null) {
2358
- notes.push(
2359
- `could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
2360
- )
2361
- }
2362
- return {
2363
- ...entry,
2364
- replies,
2365
- reply: replies.at(-1)!,
2366
- agent_acknowledgement_required:
2367
- response?.agent_acknowledgement_required ??
2368
- entry.agent_acknowledgement_required,
2369
- agent_acknowledgement_text_required:
2370
- response?.agent_acknowledgement_text_required ??
2371
- entry.agent_acknowledgement_text_required,
2372
- }
2373
- })
2374
- if (answered.length > 0) {
2375
- stageAcceptedAnswers(
2376
- ctx,
2377
- sessionId,
2378
- lateAnswers,
2379
- 0,
2380
- )
2381
- for (const answer of lateAnswers) reportAnswer(ctx, notes, answer, true)
2382
- }
2383
- if (transientTrouble) {
2384
- notes.push('could not check every pending question for a late answer before the prompt')
2385
- }
2386
- const permanentFailure = permanentReplyFailureNote(permanentFailures)
2387
- if (permanentFailure !== null) notes.push(permanentFailure)
2388
- }
2389
- const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state))
2390
- for (const entry of matched) {
2391
- ctx.log?.info('hook.retirement', {
2392
- request_id: entry.request_id,
2393
- question_id: entry.question_id,
2394
- reason: 'prompt-matched',
2395
- attempted: entry.request_id !== undefined,
2396
- proven: false,
2397
- })
2398
- }
2399
- // Park before dropping `pending`. If the process dies between these writes,
2400
- // the next hook sees both copies and dedupes them; the old order could die in
2401
- // the gap after erasing the only request/collapse/device identifiers.
2402
- updateSessionState(sessionId, ctx.env, (current) => {
2403
- const retiring = [...(current.retiring ?? [])]
2404
- const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
2405
- const unmatched = pendingList(current).filter(
2406
- (entry) => !matchedNow.some((item) => isSamePending(item, entry)),
2407
- )
2408
- for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
2409
- const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
2410
- if (
2411
- retirement !== null &&
2412
- !retiring.some((parked) => parked.request_id === retirement.request_id)
2413
- ) {
2414
- retiring.push(retirement)
2415
- }
2416
- }
2417
- const unasked = unmatched.filter((entry) => entry.request_id === undefined)
2418
- const stillLive = unmatched.filter((entry) => entry.request_id !== undefined)
2419
- const remaining = [...stillLive, ...unasked]
2420
-
2421
- // Start from the whole document so fields introduced by a newer CLI remain
2422
- // attached while this older writer applies the prompt transition it knows.
2423
- const next: SessionState = {
2424
- ...current,
2425
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2426
- last_prompt_at: ctx.now(),
2427
- }
2428
- if (remaining.length > 0) next.pending = remaining
2429
- else delete next.pending
2430
- if (retiring.length > 0) next.retiring = retiring
2431
- else delete next.retiring
2432
-
2433
- // The user typed, so whatever chain of answer-driven continuations was
2434
- // running, a human has taken the turn and the consecutive count starts
2435
- // over. Only a real prompt reaches here: the journal branch above returns
2436
- // first, and a wake route's injected message always arrives while its own
2437
- // answer is still journaled, so it can never pass for the user's presence.
2438
- if (current.continuation !== undefined) {
2439
- next.continuation = { ...current.continuation, count: 0 }
2440
- }
2441
- return next
2442
- })
2443
- // The bridge that lets a plain `notifai ask` find the hook's canonical
2444
- // session: an agent shell command gets no hook payload, and not every
2445
- // harness exports an id in the same shape.
2446
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
2447
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
2448
- }
2449
-
2450
- if (lateAnswers.length > 0) {
2451
- // UserPromptSubmit has no later hook field proving its stdout reached the
2452
- // model. Keep the journal and let the next Stop use its acknowledged
2453
- // continuation channel instead of recreating the crash-before-stdout gap.
2454
- notes.push('the late device answer will continue the agent at this turn’s Stop')
2455
- return { notes }
2456
- }
2457
- const retired = await drainRetirements(ctx, sessionId, ctx.env)
2458
- const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
2459
- const swept = [...retired, ...orphaned]
2460
- if (swept.length > 0) {
2461
- notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
2462
- }
2463
- return { notes }
2464
- }
2465
-
2466
- // ---------------------------------------------------------------------------
2467
- // Stop — the turn ended; escalate a registered question
2468
- // ---------------------------------------------------------------------------
2469
-
2470
- /**
2471
- * Only engages for a question the agent explicitly registered with
2472
- * `notifai ask`. Guessing from the last assistant message was the alternative
2473
- * and it is not worth it: a false positive here hijacks the terminal.
2474
- */
2475
- export async function handleStop(
2476
- ctx: HookContext,
2477
- envelope: HookEnvelope,
2478
- processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000,
2479
- route: EscalationDeliveryRoute = hookContinuationRoute(),
2480
- ): Promise<HookOutcome> {
2481
- const sessionId = envelope.session_id
2482
- if (!sessionId) {
2483
- gate(ctx, 'held', 'no-session', { stage: 'queued' })
2484
- return { notes: [] }
2485
- }
2486
- return runEscalationWaiter(ctx, {
2487
- sessionId,
2488
- envelope,
2489
- route,
2490
- processDeadlineAt,
2491
- })
2492
- }
2493
-
2494
- /**
2495
- * Acquire the per-session owner, waiting only when this Stop carries a
2496
- * newly registered question. The live owner sees that state on its next reply
2497
- * poll and yields; without this handoff the new question could sit unpushed for
2498
- * the older question's complete answer window.
2499
- */
2500
- async function acquireQuestionOwner(
2501
- ctx: HookContext,
2502
- sessionId: string,
2503
- hardDeadlineAt: number,
2504
- ): Promise<boolean> {
2505
- if (sessionHasEnded(sessionId, ctx.env)) return false
2506
- if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
2507
-
2508
- while (ctx.now() < hardDeadlineAt) {
2509
- const handoff = claimHandoffState(sessionId, ctx.env)
2510
- if (!handoff.hasNewQuestion) return false
2511
- const holderReleaseDeadlineAt = Math.min(
2512
- hardDeadlineAt,
2513
- handoff.ownerDeadlineAt === undefined
2514
- ? hardDeadlineAt
2515
- : handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000,
2516
- )
2517
- if (ctx.now() >= holderReleaseDeadlineAt) return false
2518
- await ctx.sleep(
2519
- Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()),
2520
- )
2521
- if (sessionHasEnded(sessionId, ctx.env)) return false
2522
- if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt)) return true
2523
- }
2524
- return false
2525
- }
2526
-
2527
- /**
2528
- * Own one registered question pipeline from its terminal-first timer through
2529
- * its final delivery or retirement. Hosts differ only in the injected route.
2530
- */
2531
- export async function runEscalationWaiter(
2532
- ctx: HookContext,
2533
- options: EscalationWaiterOptions,
2534
- ): Promise<HookOutcome> {
2535
- const notes: string[] = []
2536
- const { sessionId, envelope } = options
2537
- const hardDeadlineAt =
2538
- options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000
2539
-
2540
- if (sessionHasEnded(sessionId, ctx.env)) {
2541
- notes.push('the Agent Session already ended; no answer observer was started')
2542
- return { notes }
2543
- }
2544
-
2545
- // This event marker is diagnostic evidence in its own right. Record it
2546
- // before every early return so doctor can distinguish a working Stop route
2547
- // from the prompt-only state that previously looked healthy.
2548
- updateSessionState(sessionId, ctx.env, (current) => ({
2549
- ...current,
2550
- ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
2551
- last_stop_at: ctx.now(),
2552
- }))
2553
-
2554
- // The waiter, not its host, owns this lease. It spans grace, submission,
2555
- // polling, close fencing, route delivery, and every retirement path.
2556
- // Real clock, deliberately, not `ctx.now` — the claim answers whether
2557
- // another process is alive right now, which an injected clock cannot know.
2558
- if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
2559
- gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
2560
- notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner')
2561
- return { notes }
2562
- }
2563
- let settledAnswerThisPass = false
2564
- try {
2565
- let state = readSessionState(sessionId, ctx.env)
2566
- if (state.accepted !== undefined) {
2567
- const accepted = state.accepted
2568
- if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
2569
- const due = await reconcileAcknowledgementObligations(
2570
- ctx,
2571
- sessionId,
2572
- state.acknowledgement_due ?? [],
2573
- )
2574
- if (due.length > 0) {
2575
- gate(ctx, 'held', 'acknowledgement-required', {
2576
- request_ids: due.map((entry) => entry.request_id),
2577
- })
2578
- return {
2579
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
2580
- notes,
2581
- log: {
2582
- stage: 'acknowledgement-required',
2583
- request_ids: due.map((entry) => entry.request_id),
2584
- },
2585
- }
2586
- }
2587
- state = readSessionState(sessionId, ctx.env)
2588
- }
2589
- // Acknowledgement is per route, because routes end in different places.
2590
- //
2591
- // A blocking continuation ends at this process's stdout, which the
2592
- // harness reads only after the process exits: the recursive Stop's
2593
- // `stop_hook_active` is the acknowledgement, and persisting a separate
2594
- // "offered" bit before stdout would recreate the crash gap this journal
2595
- // exists to close. An out-of-band route ends at a write this process made
2596
- // itself and recorded here. A socket write is not proof the model acted
2597
- // on the answer — delivery is not consumption — but nothing later can
2598
- // prove more, and an answer redelivered without end is strictly worse
2599
- // than one settled on delivery. So a recorded delivery settles.
2600
- const deliveryProven =
2601
- accepted.delivered_at !== undefined || envelope.stop_hook_active === true
2602
- if (!deliveryProven) {
2603
- for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
2604
- return await deliverAcceptedAnswers(
2605
- ctx,
2606
- sessionId,
2607
- options.route,
2608
- accepted,
2609
- notes,
2610
- envelope.cwd,
2611
- )
2612
- }
2613
- settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd)
2614
- settledAnswerThisPass = true
2615
- state = readSessionState(sessionId, ctx.env)
2616
- const currentAcceptedIds = new Set(
2617
- accepted.answers.flatMap(({ pending }) =>
2618
- pending.request_id === undefined ? [] : [pending.request_id],
2619
- ),
2620
- )
2621
- const due = await reconcileAcknowledgementObligations(
2622
- ctx,
2623
- sessionId,
2624
- (state.acknowledgement_due ?? []).filter((entry) =>
2625
- currentAcceptedIds.has(entry.request_id),
2626
- ),
2627
- )
2628
- const held = holdForAcknowledgement(ctx, sessionId, due, notes)
2629
- if (held !== null) return held
2630
- state = readSessionState(sessionId, ctx.env)
2631
- }
2632
-
2633
- if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
2634
- const due = await reconcileAcknowledgementObligations(
2635
- ctx,
2636
- sessionId,
2637
- state.acknowledgement_due!,
2638
- )
2639
- const held = holdForAcknowledgement(ctx, sessionId, due, notes)
2640
- if (held !== null) return held
2641
- resetAcknowledgementBlocks(sessionId, ctx.env)
2642
- state = readSessionState(sessionId, ctx.env)
2643
- }
2644
-
2645
- const pending = pendingList(state)
2646
- if (pending.length === 0) {
2647
- const swept = [
2648
- ...(await drainRetirements(ctx, sessionId, ctx.env)),
2649
- ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
2650
- ]
2651
- if (swept.length > 0) {
2652
- notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
2653
- }
2654
- gate(ctx, 'held', 'no-question', { stage: 'queued' })
2655
- return { notes }
2656
- }
2657
-
2658
- return await handleClaimedStop(
2659
- ctx,
2660
- envelope,
2661
- sessionId,
2662
- state,
2663
- pending,
2664
- notes,
2665
- hardDeadlineAt,
2666
- options.route,
2667
- // Every route's successor turn-end, named the same way: the harness flag
2668
- // for a blocking continuation, and the settle above for a route that woke
2669
- // a brand-new turn out of band.
2670
- envelope.stop_hook_active === true || settledAnswerThisPass,
2671
- )
2672
- } finally {
2673
- releaseQuestionPush(sessionId, ctx.env)
2674
- }
2675
- }
2676
-
2677
- /**
2678
- * The blocking Stop hook's own last meter: the answer becomes this process's
2679
- * stdout, and the harness admits it as a new user turn. Exported because a
2680
- * host adapter that has other routes still falls back to this one.
2681
- */
2682
- export function hookContinuationRoute(): EscalationDeliveryRoute {
2683
- return {
2684
- kind: 'hook-continuation',
2685
- deliver: async (event) => ({
2686
- stdout: stopAnswerOutput(event.context),
2687
- commitStdout: event.commitDelivery,
2688
- notes: [],
2689
- // The harness reads this stdout after the process exits, so only the
2690
- // successor Stop can acknowledge it.
2691
- acknowledgement: 'stdout',
2692
- }),
2693
- }
2694
- }
2695
-
2696
- /**
2697
- * Hand one accepted answer to a route and record what the attempt proved.
2698
- *
2699
- * Every delivery on every route passes through here, which is what makes the
2700
- * cap below a backstop rather than a suggestion. A guard only one route
2701
- * consults is not a backstop: that is how a single answer once reached a
2702
- * session 250 times.
2703
- */
2704
- async function deliverAcceptedAnswers(
2705
- ctx: HookContext,
2706
- sessionId: string,
2707
- route: EscalationDeliveryRoute,
2708
- accepted: AcceptedAnswerDelivery,
2709
- notes: string[],
2710
- cwd?: string,
2711
- ): Promise<HookOutcome> {
2712
- const { answers: answered, remaining } = accepted
2713
- const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
2714
- if (sessionHasEnded(sessionId, ctx.env)) {
2715
- notes.push('the Agent Session ended before answer delivery; stopping this observer')
2716
- return { notes }
2717
- }
2718
- const held = accepted.held_deliveries ?? 0
2719
- if (held >= MAX_HELD_DELIVERIES) {
2720
- gate(ctx, 'held', 'delivery-limit', {
2721
- route: route.kind,
2722
- held,
2723
- limit: MAX_HELD_DELIVERIES,
2724
- request_ids: requestIds,
2725
- })
2726
- settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
2727
- notes.push(
2728
- `no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
2729
- )
2730
- return { notes }
2731
- }
2732
- const attempt = (accepted.delivery_attempts ?? 0) + 1
2733
- if (attempt > MAX_CONTINUATION_COUNT) {
2734
- gate(ctx, 'held', 'delivery-limit', {
2735
- route: route.kind,
2736
- attempts: accepted.delivery_attempts ?? 0,
2737
- limit: MAX_CONTINUATION_COUNT,
2738
- request_ids: requestIds,
2739
- })
2740
- // Settle rather than journal for ever. The answer has been offered as often
2741
- // as any working route could need, it is already in the notes of each of
2742
- // those turns, and the questions it answered still deserve retirement.
2743
- settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
2744
- notes.push(
2745
- `the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
2746
- )
2747
- return { notes }
2748
- }
2749
- // Count the attempt before making it: an attempt that dies mid-delivery still
2750
- // happened, and a counter written afterwards could be evaded by crashing.
2751
- amendAcceptedAnswers(ctx, sessionId, (current) => ({
2752
- ...current,
2753
- delivery_attempts: attempt,
2754
- }))
2755
- // Linearize delivery after the journal write. If SessionEnd acquired the
2756
- // shared state lock in between, its durable marker wins and no wake starts.
2757
- if (sessionHasEnded(sessionId, ctx.env)) {
2758
- notes.push('the Agent Session ended before answer delivery; stopping this observer')
2759
- return { notes }
2760
- }
2761
- ctx.log?.info('hook.gate', {
2762
- verdict: 'proceeding',
2763
- reason: 'answered',
2764
- stage: 'routed',
2765
- route: route.kind,
2766
- answers: answered.length,
2767
- request_ids: requestIds,
2768
- journal_recorded_at: accepted.recorded_at,
2769
- delivery_attempt: attempt,
2770
- })
2771
- let deliveryCommitted = false
2772
- const commitDelivery = (): boolean => {
2773
- if (deliveryCommitted) return true
2774
- const file = sessionStatePath(sessionId, ctx.env)
2775
- return withFileLock(`${file}.lock`, () => {
2776
- if (sessionHasEnded(sessionId, ctx.env)) return false
2777
- const current = readSessionState(sessionId, ctx.env)
2778
- if (current.accepted === undefined) return false
2779
- deliveryCommitted = true
2780
- writeSessionStateUnlocked(file, sessionId, {
2781
- ...current,
2782
- accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
2783
- })
2784
- return true
2785
- })
2786
- }
2787
- let delivered = await route.deliver({
2788
- context: answersContext(answered, remaining),
2789
- answers: answered.length,
2790
- remaining,
2791
- request_ids: requestIds,
2792
- journal_recorded_at: accepted.recorded_at,
2793
- commitDelivery,
2794
- })
2795
- if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
2796
- delivered = {
2797
- notes: [
2798
- ...(delivered.notes ?? []),
2799
- `the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
2800
- ],
2801
- log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
2802
- acknowledgement: 'held',
2803
- }
2804
- }
2805
- const deliveredRoute =
2806
- typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
2807
- const deliveredStage =
2808
- typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
2809
- if (delivered.acknowledgement === 'delivered') {
2810
- finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute)
2811
- } else if (delivered.acknowledgement === 'held') {
2812
- // Give the delivery attempt back and count the hold instead. The attempt
2813
- // was counted before the call because one that dies mid-delivery still
2814
- // happened and a counter written afterwards could be evaded by crashing —
2815
- // but a route that *returns* `held` proves both that this process survived
2816
- // and that nothing was handed over.
2817
- amendAcceptedAnswers(ctx, sessionId, (current) => ({
2818
- ...current,
2819
- delivery_attempts: accepted.delivery_attempts ?? 0,
2820
- held_deliveries: (current.held_deliveries ?? 0) + 1,
2821
- }))
2822
- }
2823
- ctx.log?.info('hook.answer', {
2824
- answered: true,
2825
- stage: deliveredStage,
2826
- route: deliveredRoute,
2827
- acknowledgement: delivered.acknowledgement,
2828
- delivery_attempt: attempt,
2829
- request_ids: requestIds,
2830
- answers: answered.length,
2831
- journal_recorded_at: accepted.recorded_at,
2832
- ...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
2833
- })
2834
- const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
2835
- if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
2836
- if (delivered.commitStdout !== undefined) outcome.commitStdout = delivered.commitStdout
2837
- if (delivered.log !== undefined) outcome.log = delivered.log
2838
- return outcome
2839
- }
2840
-
2841
- /** True while this Stop owner is still allowed a direct-wake wait. */
2842
- function ownerLeaseActive(entry: PendingQuestion, now: number): boolean {
2843
- return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now
2844
- }
2845
-
2846
- /** The complete per-session Stop decision, after this process owns the claim. */
2847
- async function handleClaimedStop(
2848
- ctx: HookContext,
2849
- envelope: HookEnvelope,
2850
- sessionId: string,
2851
- state: SessionState,
2852
- pending: PendingQuestion[],
2853
- notes: string[],
2854
- hardDeadlineAt: number,
2855
- route: EscalationDeliveryRoute,
2856
- continuingFromAnswer: boolean,
2857
- ): Promise<HookOutcome> {
2858
- const live = pending.filter((entry) => entry.request_id !== undefined)
2859
- const unasked = pending.filter((entry) => entry.request_id === undefined)
2860
- let liveToEscalate = live
2861
-
2862
- if (
2863
- ctx.harness !== undefined &&
2864
- HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported'
2865
- ) {
2866
- for (const entry of live) await closeQuietly(ctx, entry.request_id!)
2867
- await retirePendings(ctx, envelope, sessionId, pending, 'expired')
2868
- // Not `no-question`: there was a question, and this says so. Reading the
2869
- // log to find out why an ask never travelled is the whole reason these
2870
- // reasons exist, and the wrong one sent that reader looking for a question
2871
- // that was right there.
2872
- gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness })
2873
- notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
2874
- return { notes }
2875
- }
2876
-
2877
- if (live.length > 0) {
2878
- const { answered, permanentFailures } = await pollPendingReplies(
2879
- ctx,
2880
- live,
2881
- LATE_STOP_POLL_SECONDS,
2882
- )
2883
- const permanentFailure = permanentReplyFailureNote(permanentFailures)
2884
- if (permanentFailure !== null) notes.push(permanentFailure)
2885
- const permanentlyRejected = live.filter((entry) =>
2886
- permanentFailures.has(entry.request_id!),
2887
- )
2888
- await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id!)))
2889
- if (permanentlyRejected.length > 0) {
2890
- await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired')
2891
- }
2892
- if (answered.length > 0) {
2893
- const finalized = await finalizePendings(
2894
- ctx,
2895
- answered.map((entry) => entry.pending),
2896
- )
2897
- const authoritative = answered.map((entry) => {
2898
- const response = finalized.find((candidate) =>
2899
- isSamePending(candidate.pending, entry.pending),
2900
- )?.response
2901
- const replies = response?.replies.length ? response.replies : entry.replies
2902
- if (response === null) {
2903
- notes.push(
2904
- `could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
2905
- )
2906
- }
2907
- return {
2908
- ...entry,
2909
- replies,
2910
- reply: replies.at(-1)!,
2911
- agent_acknowledgement_required:
2912
- response?.agent_acknowledgement_required ??
2913
- entry.agent_acknowledgement_required,
2914
- agent_acknowledgement_text_required:
2915
- response?.agent_acknowledgement_text_required ??
2916
- entry.agent_acknowledgement_text_required,
2917
- }
2918
- })
2919
- const accepted = stageAcceptedAnswers(
2920
- ctx,
2921
- sessionId,
2922
- authoritative,
2923
- pending.length - authoritative.length - permanentlyRejected.length,
2924
- )
2925
- for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
2926
- // Anything still unasked rides the next Stop: the agent is being resumed
2927
- // with answers right now, and may not even need the rest afterwards.
2928
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2929
- }
2930
- const recoverableLive = live.filter(
2931
- (entry) => !permanentFailures.has(entry.request_id!),
2932
- )
2933
- // A spent owner has already reached the end of the answer window it owned.
2934
- // Passing those questions back into `escalate` starts a fresh ceiling and
2935
- // can monopolize the per-session claim after expiry. Independent questions
2936
- // whose lease is still running keep their successor wait.
2937
- const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
2938
- liveToEscalate = stillOwned
2939
- if (unasked.length === 0) {
2940
- if (recoverableLive.length === 0) return { notes }
2941
- if (stillOwned.length === 0) {
2942
- const requestIdSummary = summarizeRequestIds(recoverableLive)
2943
- ctx.log?.info('hook.answer', {
2944
- answered: false,
2945
- stage: 'queued',
2946
- request_ids: requestIdSummary.ids,
2947
- owner_spent: true,
2948
- })
2949
- notes.push(
2950
- `${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`,
2951
- )
2952
- return { notes }
2953
- }
2954
- return await escalate(
2955
- ctx,
2956
- envelope,
2957
- sessionId,
2958
- [],
2959
- stillOwned,
2960
- notes,
2961
- hardDeadlineAt,
2962
- route,
2963
- )
2964
- }
2965
- // Questions registered after the earlier push still owe the user their
2966
- // notification; fall through and escalate just those, plus any live
2967
- // question whose owner lease is still running.
2968
- }
2969
-
2970
- // A Stop answer may immediately produce a legitimate follow-up question.
2971
- // Allow that new generation, but never re-run an old pending question and
2972
- // never let answer continuations become an unbounded agent loop.
2973
- if (envelope.stop_hook_active === true) {
2974
- const continuation = state.continuation
2975
- const isNew =
2976
- continuation !== undefined &&
2977
- unasked.some(
2978
- (entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
2979
- )
2980
- if (!isNew) {
2981
- gate(ctx, 'held', 'continuation-repeat')
2982
- notes.push('already continuing from an answer; not asking again this turn')
2983
- return { notes }
2984
- }
2985
- }
2986
- // The cap is deliberately not keyed to `stop_hook_active`. That flag names one
2987
- // route's continuation; a route that wakes a brand-new turn never sets it, and
2988
- // a limit only one route consults bounds only that route. `continuingFromAnswer`
2989
- // is the same event named for every route, so the chain of answer → follow-up
2990
- // question → answer is bounded whatever delivered it.
2991
- if (
2992
- continuingFromAnswer &&
2993
- state.continuation !== undefined &&
2994
- state.continuation.count >= MAX_CONTINUATION_COUNT
2995
- ) {
2996
- gate(ctx, 'held', 'continuation-limit', {
2997
- count: state.continuation.count,
2998
- limit: MAX_CONTINUATION_COUNT,
2999
- route: route.kind,
3000
- })
3001
- notes.push(
3002
- `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
3003
- )
3004
- return { notes }
3005
- }
3006
- // Silent to the user by design — they switched routing off, so saying so on
3007
- // every turn would be nagging about their own setting. That silence is also
3008
- // why it belongs in the log: from outside, "ask_notifications = false" and "a
3009
- // bug ate my question" look exactly the same.
3010
- if (!ctx.config.ask_notifications.value) {
3011
- gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
3012
- return { notes }
3013
- }
3014
- gate(ctx, 'proceeding', 'proceeding', {
3015
- unasked: unasked.length,
3016
- already_live: liveToEscalate.length,
3017
- grace_seconds: ctx.config.ask_grace_seconds.value,
3018
- })
3019
- return await escalate(
3020
- ctx,
3021
- envelope,
3022
- sessionId,
3023
- unasked,
3024
- liveToEscalate,
3025
- notes,
3026
- hardDeadlineAt,
3027
- route,
3028
- )
3029
- }
3030
-
3031
- /** The escalation itself, split out so the claim is released on every path. */
3032
- async function escalate(
3033
- ctx: HookContext,
3034
- envelope: HookEnvelope,
3035
- sessionId: string,
3036
- unasked: PendingQuestion[],
3037
- alreadyLive: PendingQuestion[],
3038
- notes: string[],
3039
- hardDeadlineAt: number,
3040
- route: EscalationDeliveryRoute,
3041
- ): Promise<HookOutcome> {
3042
- // The questions still owe the user their terminal-first window before
3043
- // anything reaches their devices — measured from the oldest registration,
3044
- // because that is the question that has waited longest.
3045
- const ceilingAt = hardDeadlineAt
3046
- if (unasked.length > 0 && alreadyLive.length === 0) {
3047
- const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
3048
- await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
3049
- ctx.log?.debug('hook.gate', {
3050
- verdict: 'grace',
3051
- reason: 'elapsed' satisfies GateReason,
3052
- waited_from: oldest,
3053
- grace_seconds: ctx.config.ask_grace_seconds.value,
3054
- })
3055
- }
3056
- // Phase one: every registered question reaches the user's devices, each as
3057
- // its own notification — one ask never stands in for another.
3058
- const submitted: PendingQuestion[] = []
3059
- const admissionAnswers: AnsweredPending[] = []
3060
- for (const entry of unasked) {
3061
- // The service owns how long the answer is accepted. This process begins
3062
- // before submission, so its larger maximum-window budget includes startup
3063
- // headroom and remains alive through the complete committed window.
3064
- const replyWindowSeconds = ctx.config.reply_window_seconds.value
3065
- if (
3066
- ceilingAt - ctx.now() <
3067
- (replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000
3068
- ) {
3069
- notes.push(
3070
- 'too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner',
3071
- )
3072
- continue
3073
- }
3074
- const ownerDeadlineAt = ceilingAt
3075
- const questions = pendingQuestions(entry)
3076
- const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
3077
- let intent = entry.submission
3078
- if (intent === undefined) {
3079
- if (entry.body === undefined) {
3080
- notes.push('registered question has no canonical body; register it again with this CLI')
3081
- continue
3082
- }
3083
- const prepared = await prepareQuestionSubmission(ctx, {
3084
- // Type and Project have their own fields; the title is only substance.
3085
- title: questions[0]!.text,
3086
- body: entry.body,
3087
- questions,
3088
- ...(entry.media !== undefined ? { media: entry.media } : {}),
3089
- ...(entry.project !== undefined ? { project: entry.project } : {}),
3090
- ...(eventSource !== undefined ? { source: eventSource } : {}),
3091
- windowSeconds: replyWindowSeconds,
3092
- ownerDeadlineAt,
3093
- })
3094
- if ('error' in prepared) {
3095
- ctx.log?.error('hook.pushed', { ok: false, message: prepared.error })
3096
- notes.push(prepared.error)
3097
- continue
3098
- }
3099
- intent = prepared
3100
- // Durable before submit. If the server commits and the response is lost,
3101
- // the reserved request id still lets this owner poll and finalize the
3102
- // exact card; a successor can also replay the frozen draft/key.
3103
- updateSessionState(sessionId, ctx.env, (current) => {
3104
- const list = pendingList(current)
3105
- const index = list.findIndex((candidate) => isSamePending(candidate, entry))
3106
- if (index < 0) return current
3107
- const next = [...list]
3108
- next[index] = { ...next[index]!, submission: prepared }
3109
- return { ...current, pending: next }
3110
- })
3111
- } else if (intent.owner_deadline_at <= ctx.now()) {
3112
- // The frozen wire identity survives a crashed owner, but its local owner
3113
- // lease does not. Re-arm only the local deadline; request id, key,
3114
- // targets, and draft remain byte-identical for idempotent replay.
3115
- intent = { ...intent, owner_deadline_at: ceilingAt }
3116
- const rearmed = intent
3117
- updateSessionState(sessionId, ctx.env, (current) => {
3118
- const list = pendingList(current)
3119
- const index = list.findIndex((candidate) => isSamePending(candidate, entry))
3120
- if (index < 0) return current
3121
- const next = [...list]
3122
- next[index] = { ...next[index]!, submission: rearmed }
3123
- return { ...current, pending: next }
3124
- })
3125
- }
3126
- if (ctx.now() >= intent.owner_deadline_at) {
3127
- notes.push('the owner lifetime ended before submission; preserving the frozen intent')
3128
- continue
3129
- }
3130
- if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
3131
- notes.push(
3132
- 'setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely',
3133
- )
3134
- continue
3135
- }
3136
- if (sessionHasEnded(sessionId, ctx.env)) {
3137
- notes.push('the Agent Session ended before submission; preserving no live observer')
3138
- continue
3139
- }
3140
- let receipt: SubmissionReceipt | undefined
3141
- let admissionConfirmed = false
3142
- try {
3143
- receipt = await submitQuestion(ctx, intent)
3144
- admissionConfirmed = true
3145
- } catch (caught) {
3146
- let err: unknown = caught
3147
- if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
3148
- ctx.log?.error('hook.pushed', {
3149
- ok: false,
3150
- request_id: intent.request_id,
3151
- status: err.status,
3152
- code: err.code,
3153
- message: err.message,
3154
- })
3155
- if (entry.submission !== undefined) {
3156
- notes.push(
3157
- `question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`,
3158
- )
3159
- clearFrozenSubmission(sessionId, ctx.env, entry)
3160
- if (entry.body === undefined) {
3161
- dropPendingQuestion(sessionId, ctx.env, entry)
3162
- notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it')
3163
- continue
3164
- }
3165
- const reminted = await prepareQuestionSubmission(ctx, {
3166
- title: questions[0]!.text,
3167
- body: entry.body,
3168
- questions,
3169
- ...(entry.media !== undefined ? { media: entry.media } : {}),
3170
- ...(entry.project !== undefined ? { project: entry.project } : {}),
3171
- ...(eventSource !== undefined ? { source: eventSource } : {}),
3172
- windowSeconds: replyWindowSeconds,
3173
- ownerDeadlineAt,
3174
- })
3175
- if ('error' in reminted) {
3176
- dropPendingQuestion(sessionId, ctx.env, entry)
3177
- notes.push(reminted.error)
3178
- continue
3179
- }
3180
- intent = reminted
3181
- updateSessionState(sessionId, ctx.env, (current) => {
3182
- const list = pendingList(current)
3183
- const index = list.findIndex((candidate) => isSamePending(candidate, entry))
3184
- if (index < 0) return current
3185
- const next = [...list]
3186
- next[index] = { ...next[index]!, submission: reminted }
3187
- return { ...current, pending: next }
3188
- })
3189
- try {
3190
- if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
3191
- notes.push(
3192
- 'draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner',
3193
- )
3194
- continue
3195
- }
3196
- if (sessionHasEnded(sessionId, ctx.env)) {
3197
- notes.push('the Agent Session ended before recovered submission')
3198
- continue
3199
- }
3200
- receipt = await submitQuestion(ctx, intent)
3201
- admissionConfirmed = true
3202
- } catch (retryErr) {
3203
- if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
3204
- dropPendingQuestion(sessionId, ctx.env, entry)
3205
- notes.push(
3206
- `reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`,
3207
- )
3208
- ctx.log?.error('hook.pushed', {
3209
- ok: false,
3210
- request_id: intent.request_id,
3211
- status: retryErr.status,
3212
- code: retryErr.code,
3213
- message: retryErr.message,
3214
- })
3215
- continue
3216
- }
3217
- err = retryErr
3218
- }
3219
- } else {
3220
- dropPendingQuestion(sessionId, ctx.env, entry)
3221
- notes.push(
3222
- `question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`,
3223
- )
3224
- continue
3225
- }
3226
- }
3227
- if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
3228
- notes.push(
3229
- `question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
3230
- )
3231
- ctx.log?.error('hook.pushed', {
3232
- ok: false,
3233
- request_id: intent.request_id,
3234
- status: err.status,
3235
- code: err.code,
3236
- message: err.message,
3237
- })
3238
- continue
3239
- }
3240
- if (!admissionConfirmed) {
3241
- notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
3242
- ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
3243
- }
3244
- }
3245
- if (admissionConfirmed) {
3246
- ctx.log?.info('hook.pushed', {
3247
- ok: true,
3248
- request_id: intent.request_id,
3249
- devices: intent.device_ids.length,
3250
- questions: questions.length,
3251
- text_chars: questions[0]!.text.length,
3252
- })
3253
- }
3254
- const committedReplyDeadline =
3255
- receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
3256
- ? intent.owner_deadline_at
3257
- : Date.parse(receipt.reply_expires_at)
3258
- const live: PendingQuestion = {
3259
- ...entry,
3260
- ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
3261
- request_id: intent.request_id,
3262
- collapse_key: intent.collapse_key,
3263
- device_ids: intent.device_ids,
3264
- // The server's committed answer deadline is authoritative. The local
3265
- // owner deadline starts earlier and includes startup headroom, so it may
3266
- // be later but must never be earlier.
3267
- reply_deadline_at: Number.isFinite(committedReplyDeadline)
3268
- ? committedReplyDeadline
3269
- : intent.owner_deadline_at,
3270
- owner_deadline_at: intent.owner_deadline_at,
3271
- }
3272
- if (intent.draft.source === undefined) delete live.source
3273
- delete live.submission
3274
- if (live.reply_deadline_at! > live.owner_deadline_at!) {
3275
- const response = await finalizeReplies(ctx, live.request_id!)
3276
- if (response === null) {
3277
- // Closing was unreachable, so the question is still potentially live.
3278
- // Preserve it in the exact session instead of demoting it to the orphan
3279
- // retirement queue, whose later close has no route for an answer.
3280
- updateSessionState(sessionId, ctx.env, (current) => {
3281
- const list = pendingList(current)
3282
- const index = list.findIndex((candidate) => isSamePending(candidate, entry))
3283
- if (index < 0) {
3284
- // Terminal input removed this registration while the anomalous
3285
- // close was in flight. Preserve retirement identity exactly as
3286
- // normal submit promotion does; the observer must not become the
3287
- // only remaining record of a live card.
3288
- const retirement = retiringQuestion(
3289
- live,
3290
- 'answered_elsewhere',
3291
- envelope.cwd,
3292
- )!
3293
- const retiring = [...(current.retiring ?? [])]
3294
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
3295
- retiring.push(retirement)
3296
- }
3297
- return { ...current, retiring }
3298
- }
3299
- const next = [...list]
3300
- next[index] = live
3301
- return { ...current, pending: next }
3302
- })
3303
- submitted.push(live)
3304
- notes.push(
3305
- 'the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery',
3306
- )
3307
- continue
3308
- } else if (response.replies.length > 0) {
3309
- admissionAnswers.push({
3310
- pending: live,
3311
- reply: response.replies.at(-1)!,
3312
- replies: response.replies,
3313
- agent_acknowledgement_required: response.agent_acknowledgement_required,
3314
- agent_acknowledgement_text_required:
3315
- response.agent_acknowledgement_text_required,
3316
- })
3317
- }
3318
- dropPendingQuestion(sessionId, ctx.env, entry)
3319
- notes.push(
3320
- 'the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early',
3321
- )
3322
- continue
3323
- }
3324
- // Record what is now live on the user's devices BEFORE any wait. If we
3325
- // only learned these ids afterwards, a question that timed out would
3326
- // leave no trace, and the user returning to the terminal could never
3327
- // retire it — the notification would stay answerable for an hour with
3328
- // nobody listening.
3329
- if (admissionConfirmed) {
3330
- updateSessionState(sessionId, ctx.env, (current) => {
3331
- const list = pendingList(current)
3332
- const index = list.findIndex(
3333
- (candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
3334
- )
3335
- if (index >= 0) {
3336
- const next = [...list]
3337
- next[index] = live
3338
- return { ...current, pending: next }
3339
- }
3340
- // The entry vanished while the submit was in flight (the user's prompt
3341
- // wiped the queue). The delivered notification must still be retirable,
3342
- // so park it rather than lose its only identifiers.
3343
- const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd)!
3344
- const retiring = [...(current.retiring ?? [])]
3345
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
3346
- retiring.push(retirement)
3347
- }
3348
- return { ...current, retiring }
3349
- })
3350
- }
3351
- if (sessionHasEnded(sessionId, ctx.env)) {
3352
- // SessionEnd may have raced either the submit or promotion. Its snapshot
3353
- // usually already queued the frozen intent; this idempotent add closes
3354
- // the other ordering where the request committed just after cleanup.
3355
- orphanRetirements(
3356
- ctx.env,
3357
- [retiringQuestion(live, 'expired', envelope.cwd)!],
3358
- ctx.now(),
3359
- )
3360
- notes.push('the Agent Session ended during submission; queued the question for retirement')
3361
- continue
3362
- }
3363
- submitted.push(live)
3364
- }
3365
-
3366
- if (admissionAnswers.length > 0) {
3367
- const accepted = stageAcceptedAnswers(
3368
- ctx,
3369
- sessionId,
3370
- admissionAnswers,
3371
- pendingList(readSessionState(sessionId, ctx.env)).length,
3372
- )
3373
- for (const answer of admissionAnswers) reportAnswer(ctx, notes, answer, false)
3374
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3375
- }
3376
-
3377
- const staleLive = alreadyLive.filter(
3378
- (entry) =>
3379
- entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now(),
3380
- )
3381
- const finalizedStale = await finalizePendings(ctx, staleLive)
3382
- const staleAnswers = finalizedStale
3383
- .map(finalizedAnswer)
3384
- .filter((entry): entry is AnsweredPending => entry !== null)
3385
- const staleSilence = finalizedStale
3386
- .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
3387
- .map((entry) => entry.pending)
3388
- const staleUnproven = finalizedStale.filter((entry) => entry.response === null)
3389
- if (staleSilence.length > 0) {
3390
- await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired')
3391
- notes.push(
3392
- `expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`,
3393
- )
3394
- }
3395
- if (staleUnproven.length > 0) {
3396
- notes.push(
3397
- `could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`,
3398
- )
3399
- }
3400
- if (staleAnswers.length > 0) {
3401
- const remaining = Math.max(
3402
- 0,
3403
- pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
3404
- )
3405
- const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
3406
- for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
3407
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3408
- }
3409
- const waitingOn = [
3410
- ...alreadyLive.filter(
3411
- (entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now(),
3412
- ),
3413
- ...submitted,
3414
- ]
3415
- if (waitingOn.length === 0) return { notes }
3416
-
3417
- // Phase two: keep the same owner alive across every committed answer window.
3418
- // Different questions may expire at different times, so finalize each one at
3419
- // its own server deadline and continue waiting on the rest. Reserving time by
3420
- // closing early would make the advertised reply window untrue.
3421
- let activeWaiting = waitingOn
3422
- let timeoutSeconds = 0
3423
- let waited: Awaited<ReturnType<typeof waitForAnyReply>>
3424
- for (;;) {
3425
- const nextReplyDeadline = Math.min(
3426
- ...activeWaiting.map((entry) => entry.reply_deadline_at!),
3427
- )
3428
- const waitDeadline = Math.min(ceilingAt, nextReplyDeadline)
3429
- timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000))
3430
- waited = await waitForAnyReply(
3431
- ctx,
3432
- activeWaiting.map((entry) => entry.request_id!),
3433
- timeoutSeconds,
3434
- () =>
3435
- waiterInterruption(
3436
- sessionId,
3437
- ctx.env,
3438
- activeWaiting.map((entry) => entry.request_id!),
3439
- ),
3440
- )
3441
- if (waited.interrupted !== null) {
3442
- notes.push(
3443
- waited.interrupted === 'new-question'
3444
- ? 'yielding the answer owner so a newly registered question can be sent now'
3445
- : 'answer ownership ended locally; stopping this observer without delivering into a closed session',
3446
- )
3447
- return { notes }
3448
- }
3449
- const currentRequestIds = new Set(
3450
- pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) =>
3451
- [entry.request_id, entry.submission?.request_id].filter(
3452
- (requestId): requestId is string => requestId !== undefined,
3453
- ),
3454
- ),
3455
- )
3456
- const removedDuringPoll = activeWaiting.filter(
3457
- (entry) => !currentRequestIds.has(entry.request_id!),
3458
- )
3459
- for (const entry of removedDuringPoll) {
3460
- waited.byRequest.delete(entry.request_id!)
3461
- waited.permanentFailures.delete(entry.request_id!)
3462
- }
3463
- if (removedDuringPoll.length > 0) {
3464
- notes.push(
3465
- `${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`,
3466
- )
3467
- activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id!))
3468
- if (activeWaiting.length === 0) return { notes }
3469
- }
3470
- const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
3471
- if (permanentFailure !== null) notes.push(permanentFailure)
3472
- if (waited.byRequest.size > 0) break
3473
-
3474
- // A permanent rejection is authoritative immediately. Time-based expiry is
3475
- // authoritative only when this owner reached that question's committed
3476
- // server deadline; reaching the process ceiling first preserves the record.
3477
- const expired = activeWaiting.filter(
3478
- (entry) =>
3479
- entry.reply_deadline_at === undefined ||
3480
- entry.reply_deadline_at <= waitDeadline ||
3481
- waited.permanentFailures.has(entry.request_id!),
3482
- )
3483
- const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry))
3484
- const finalized = await finalizePendings(ctx, expired)
3485
- const finalAnswers = finalized
3486
- .map(finalizedAnswer)
3487
- .filter((entry): entry is AnsweredPending => entry !== null)
3488
- const confirmedSilent = finalized
3489
- .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
3490
- .map((entry) => entry.pending)
3491
- const unproven = finalized.filter((entry) => entry.response === null)
3492
- if (confirmedSilent.length > 0) {
3493
- await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired')
3494
- }
3495
- if (unproven.length > 0) {
3496
- notes.push(
3497
- `could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`,
3498
- )
3499
- }
3500
- if (finalAnswers.length > 0) {
3501
- const remaining = Math.max(
3502
- 0,
3503
- pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
3504
- )
3505
- const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
3506
- for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
3507
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3508
- }
3509
- if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
3510
- activeWaiting = stillAnswerable
3511
- continue
3512
- }
3513
-
3514
- const requestIdSummary = summarizeRequestIds(waitingOn)
3515
- ctx.log?.info('hook.answer', {
3516
- answered: false,
3517
- stage: 'queued',
3518
- request_ids: requestIdSummary.ids,
3519
- degraded: waited.degraded,
3520
- permanent_failures: waited.permanentFailures.size,
3521
- waited_seconds: timeoutSeconds,
3522
- })
3523
- if (stillAnswerable.length > 0) {
3524
- notes.push(
3525
- `${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`,
3526
- )
3527
- } else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
3528
- notes.push(
3529
- waited.degraded
3530
- ? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
3531
- : 'no answer in time; the question expired with its continuation owner',
3532
- )
3533
- }
3534
- return { notes }
3535
- }
3536
-
3537
- const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id!))
3538
- const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
3539
- const answered: AnsweredPending[] = []
3540
- for (const finalized of finalizedAnswered) {
3541
- const entry = finalized.pending
3542
- const replies = waited.byRequest.get(entry.request_id!)
3543
- if (replies === undefined) continue
3544
- // The fenced set wins because it includes corrections that committed
3545
- // while close was waiting for earlier writers. If finalization itself was
3546
- // unreachable, never discard the durable reply already observed.
3547
- const finalReplies = finalized.response?.replies ?? []
3548
- const authoritative = finalReplies.length > 0 ? finalReplies : replies
3549
- answered.push({
3550
- pending: entry,
3551
- reply: authoritative.at(-1)!,
3552
- replies: authoritative,
3553
- agent_acknowledgement_required:
3554
- finalized.response?.agent_acknowledgement_required,
3555
- agent_acknowledgement_text_required:
3556
- finalized.response?.agent_acknowledgement_text_required,
3557
- })
3558
- if (finalized.response === null) {
3559
- notes.push(
3560
- `could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
3561
- )
3562
- }
3563
- }
3564
- const accepted = stageAcceptedAnswers(
3565
- ctx,
3566
- sessionId,
3567
- answered,
3568
- Math.max(
3569
- 0,
3570
- pendingList(readSessionState(sessionId, ctx.env)).length - answered.length,
3571
- ),
3572
- )
3573
- for (const answer of answered) reportAnswer(ctx, notes, answer, false)
3574
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3575
- }
3576
-
3577
- // ---------------------------------------------------------------------------
3578
- // SessionEnd — local cleanup only
3579
- // ---------------------------------------------------------------------------
3580
-
3581
- /**
3582
- * Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
3583
- * second, so this cannot make a network call. It drops the local marker — but
3584
- * first moves anything still live on the user's devices into the global
3585
- * retirement queue, because this file was the only record of those ids and no
3586
- * hook for this session will ever run again. A question whose
3587
- * agent just exited can receive no answer, so it is orphaned as `expired`.
3588
- */
3589
- export function handleSessionEnd(
3590
- env: NodeJS.ProcessEnv,
3591
- envelope: HookEnvelope,
3592
- now: number = Date.now(),
3593
- ): HookOutcome {
3594
- const notes: string[] = []
3595
- const sessionId = envelope.session_id
3596
- if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
3597
- // Publish cancellation before reading or clearing anything. In-flight Stop
3598
- // writers use the same session lock, so none can recreate state after this.
3599
- markSessionEnded(sessionId, env, now)
3600
- if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
3601
-
3602
- const state = readSessionState(sessionId, env)
3603
- const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
3604
- const retirementCandidates = [
3605
- ...pendingList(state),
3606
- ...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
3607
- ]
3608
- for (const entry of retirementCandidates) {
3609
- try {
3610
- const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
3611
- if (
3612
- orphan !== null &&
3613
- !orphans.some((candidate) => candidate.request_id === orphan.request_id)
3614
- ) {
3615
- orphans.push(orphan)
3616
- }
3617
- } catch (err) {
3618
- notes.push(err instanceof Error ? err.message : String(err))
3619
- // Preserve the only identifiers instead of turning an explicit corrupt
3620
- // state into an unretirable question during SessionEnd cleanup.
3621
- return {
3622
- notes,
3623
- log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
3624
- }
3625
- }
3626
- }
3627
- if (orphans.length > 0) {
3628
- orphanRetirements(env, orphans, now)
3629
- notes.push(
3630
- `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
3631
- )
3632
- }
3633
- if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
3634
- const preserved: SessionState = { ...state }
3635
- delete preserved.pending
3636
- delete preserved.retiring
3637
- delete preserved.acknowledgement_blocks
3638
- if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
3639
- delete preserved.acknowledgement_due
3640
- }
3641
- writeSessionState(sessionId, env, preserved)
3642
- notes.push(
3643
- state.accepted !== undefined
3644
- ? 'preserved an accepted device answer for this exact session to resume'
3645
- : 'preserved required Agent Acknowledgement obligations for this exact session',
3646
- )
3647
- return {
3648
- notes,
3649
- log: {
3650
- outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
3651
- queued_retirements: orphans.length,
3652
- accepted_answers: state.accepted?.answers.length ?? 0,
3653
- acknowledgement_due: state.acknowledgement_due?.length ?? 0,
3654
- },
3655
- }
3656
- }
3657
- clearSessionState(sessionId, env)
3658
- return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
3659
- }
3660
-
3661
- // ---------------------------------------------------------------------------
3662
- // ask — register a question for the turn's end
3663
- // ---------------------------------------------------------------------------
3664
-
3665
- /**
3666
- * Registrations still waiting for their first push. More than this in one turn
3667
- * means an agent is looping, not asking, and related questions belong in one
3668
- * `ask --form` instead.
3669
- *
3670
- * Deliberately counts only unasked entries. A question already on the user's
3671
- * devices is waiting on a person, not on the agent, and an answer stays
3672
- * accepted for a day — so counting live questions here would have turned a
3673
- * patient user into a reason the agent could no longer ask anything.
3674
- */
3675
- export const MAX_PENDING_QUESTIONS = 4
3676
-
3677
- /**
3678
- * Everything this session has open at once, asked or not. The lock screen is
3679
- * the shared resource being protected here, not the agent's turn.
3680
- */
3681
- export const MAX_LIVE_QUESTIONS = 10
3682
-
3683
- /**
3684
- * A session may hold several registered questions at once: a new `ask` never
3685
- * ends an earlier one. Each reaches the user as its own notification and is
3686
- * answerable independently. Superseding is reply semantics — a later reply
3687
- * corrects an earlier reply to the same question (that correction model lives
3688
- * server-side) — never question semantics: the old single-slot model silently
3689
- * discarded a question the moment a second was registered, and the user was
3690
- * pointed at a notification that never existed (2026-08-09).
3691
- *
3692
- * The queue is keyed on the session, not the project: several agents may be
3693
- * running in one project at once, and their questions must never interfere.
3694
- */
3695
- export function registerQuestion(
3696
- sessionId: string,
3697
- env: NodeJS.ProcessEnv,
3698
- question: PendingQuestion,
3699
- now: number = Date.now(),
3700
- ): string {
3701
- let full: 'unasked' | 'live' | null = null
3702
- const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`
3703
- updateSessionState(sessionId, env, (state) => {
3704
- const pending = pendingList(state)
3705
- const unasked = pending.filter((entry) => entry.request_id === undefined)
3706
- if (unasked.length >= MAX_PENDING_QUESTIONS) {
3707
- full = 'unasked'
3708
- return state
3709
- }
3710
- if (pending.length >= MAX_LIVE_QUESTIONS) {
3711
- full = 'live'
3712
- return state
3713
- }
3714
- return {
3715
- ...state,
3716
- pending: [
3717
- ...pending,
3718
- {
3719
- asked_at: now,
3720
- ...question,
3721
- question_id: questionId,
3722
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
3723
- },
3724
- ],
3725
- }
3726
- })
3727
- if (full === 'unasked') {
3728
- throw new Error(
3729
- `${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
3730
- 'Combine related questions into one `notifai ask --form` instead of registering more.',
3731
- )
3732
- }
3733
- if (full === 'live') {
3734
- throw new Error(
3735
- `${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
3736
- 'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.',
3737
- )
3738
- }
3739
- return questionId
3740
- }
3741
-
3742
- /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
3743
- export function parseHookInput(raw: string): HookEnvelope {
3744
- if (raw.trim() === '') return {}
3745
- try {
3746
- const parsed: unknown = JSON.parse(raw)
3747
- return typeof parsed === 'object' && parsed !== null ? (parsed as HookEnvelope) : {}
3748
- } catch {
3749
- return {}
3750
- }
3751
- }
3752
-
3753
- export function resolveHookConfig(
3754
- cwd: string,
3755
- env: NodeJS.ProcessEnv,
3756
- sessionId: string | undefined,
3757
- ): CliConfig {
3758
- return loadConfig({ cwd, env, sessionId })
3759
- }