@raidiant/notifai 10.1.1 → 10.1.3

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