@raidiant/notifai 0.3.0 → 0.5.0

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 (87) hide show
  1. package/README.md +19 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +97 -36
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/claude-wake.test.ts +320 -0
  52. package/src/claude-wake.ts +489 -0
  53. package/src/client.test.ts +12 -0
  54. package/src/client.ts +72 -4
  55. package/src/codex-wake.test.ts +253 -0
  56. package/src/codex-wake.ts +288 -0
  57. package/src/commands.test.ts +1261 -77
  58. package/src/commands.ts +1482 -375
  59. package/src/config-schema.test.ts +3 -3
  60. package/src/config-schema.ts +57 -44
  61. package/src/config.test.ts +22 -16
  62. package/src/config.ts +81 -66
  63. package/src/file-lock-process-worker.test.ts +64 -0
  64. package/src/file-lock.test.ts +279 -0
  65. package/src/file-lock.ts +318 -0
  66. package/src/harnesses.test.ts +34 -0
  67. package/src/harnesses.ts +57 -0
  68. package/src/hook-adapter.test.ts +187 -0
  69. package/src/hook-adapter.ts +212 -0
  70. package/src/hook-input.test.ts +39 -0
  71. package/src/hook-input.ts +72 -0
  72. package/src/hooks.test.ts +1985 -417
  73. package/src/hooks.ts +1338 -461
  74. package/src/install-hooks.test.ts +131 -41
  75. package/src/install-hooks.ts +284 -84
  76. package/src/interactive.ts +3 -8
  77. package/src/logging-process-worker.test.ts +39 -0
  78. package/src/logging.test.ts +453 -0
  79. package/src/logging.ts +639 -0
  80. package/src/main.ts +113 -32
  81. package/src/opencode-plugin.ts +15 -16
  82. package/src/session-end-log-lock-worker.test.ts +35 -0
  83. package/src/skill.test.ts +34 -0
  84. package/src/ui/config-view.test.ts +9 -7
  85. package/src/ui/config-view.ts +3 -0
  86. package/src/idle.test.ts +0 -32
  87. package/src/idle.ts +0 -41
package/src/hooks.ts CHANGED
@@ -11,8 +11,19 @@ import {
11
11
  writeFileSync,
12
12
  } from 'node:fs'
13
13
  import path from 'node:path'
14
- import type { LifecycleEndState, QuestionT, ReplyView } from '@raidiant/notifai-protocol'
15
- import type { ApiClient } from './client.js'
14
+ import type {
15
+ LifecycleEndState,
16
+ ListRepliesResponse,
17
+ NotificationDraftT,
18
+ QuestionT,
19
+ ReplyView,
20
+ SubmissionReceipt,
21
+ } from '@raidiant/notifai-protocol'
22
+ import {
23
+ ApiCallError,
24
+ isRetryableReplyPollError,
25
+ type ApiClient,
26
+ } from './client.js'
16
27
  import {
17
28
  loadConfig,
18
29
  projectSessionPointerPath,
@@ -23,20 +34,30 @@ import {
23
34
  } from './config.js'
24
35
  import { buildDraft } from './send.js'
25
36
  import { atomicWriteFileSync } from './atomic-file.js'
37
+ import { withFileLock } from './file-lock.js'
38
+ import type { Logger } from './logging.js'
39
+ import {
40
+ HARNESS_CAPABILITIES,
41
+ HARNESSES,
42
+ type DeliveryRoute,
43
+ type Harness,
44
+ } from './harnesses.js'
26
45
 
27
46
  /**
28
47
  * Harness hook handlers.
29
48
  *
30
49
  * The supported harnesses expose the same useful lifecycle joints: a turn-end
31
- * event and an event that fires when the user submits a prompt. Claude Code,
32
- * Codex, and Cursor can continue directly from a turn-end answer. OpenCode
33
- * preserves the pending answer at turn end and injects it on the next prompt.
34
- * No question detection or context-window state is needed in either path.
50
+ * event and an event that fires when the user submits a prompt. Claude Code
51
+ * and Codex can continue directly from a turn-end answer. Harnesses without a
52
+ * proven exact-session continuation fail closed at question admission.
35
53
  *
36
- * The load-bearing constraint is that these hooks are synchronous: while one
37
- * blocks, the harness cannot show its own prompt either. So a hook may only
38
- * take over when the user is demonstrably absent. Present user, or no evidence
39
- * either way, means exit immediately and let the terminal do its job.
54
+ * Where the user is standing no longer decides anything here. It used to: the
55
+ * turn-end hook held the terminal for as long as it waited, so waiting was
56
+ * only defensible while the person was demonstrably not at the keyboard. On
57
+ * Claude Code the handler now returns instantly and the waiter runs out of
58
+ * band, and nothing is monopolised in the first place — so the question is
59
+ * simply whether the user wants to be reached, which they answer once in
60
+ * configuration rather than implicitly with every keystroke.
40
61
  */
41
62
 
42
63
  /** Fields we read from harness hook JSON. Everything else is passed through. */
@@ -57,6 +78,8 @@ export interface HookEnvelope {
57
78
  export interface SessionState {
58
79
  /** Epoch ms of the user's last prompt in this session — our presence signal. */
59
80
  last_prompt_at?: number
81
+ /** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
82
+ last_stop_at?: number
60
83
  /**
61
84
  * Questions registered by `notifai ask`, in registration order, each
62
85
  * awaiting escalation or its answer. A list, deliberately: registering a
@@ -79,11 +102,42 @@ export interface SessionState {
79
102
  * costs nothing and a missed one costs a stale notification for ever.
80
103
  */
81
104
  retiring?: RetiringQuestion[]
82
- /** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
105
+ /**
106
+ * Tracks bounded Stop continuations so a follow-up ask is delivered once.
107
+ * `count` is how many answer generations have run consecutively without the
108
+ * user taking a turn themselves; their next prompt starts it over.
109
+ */
83
110
  continuation?: {
84
111
  answered_at: number
85
112
  count: number
86
113
  }
114
+ /**
115
+ * A phone answer durably captured but not yet acknowledged. It stays here
116
+ * until a delivery is acknowledged — by the route's own write, or by the
117
+ * successor Stop of a blocking continuation — so a crash before the answer
118
+ * reaches the harness replays it instead of erasing it.
119
+ */
120
+ accepted?: AcceptedAnswerDelivery
121
+ }
122
+
123
+ interface AcceptedAnswerDelivery {
124
+ answers: AnsweredPending[]
125
+ remaining: number
126
+ recorded_at: number
127
+ /**
128
+ * Epoch ms when a route's own write handed this answer to the harness, with
129
+ * the route that performed it. Recorded so that "this answer was delivered"
130
+ * is a fact on the journal rather than something inferred from a harness flag
131
+ * that only one route ever sets.
132
+ */
133
+ delivered_at?: number
134
+ delivered_route?: string
135
+ /**
136
+ * How many times this answer has been handed to any route. The route-agnostic
137
+ * loop backstop: bounded by `MAX_CONTINUATION_COUNT` in one place every route
138
+ * passes through.
139
+ */
140
+ delivery_attempts?: number
87
141
  }
88
142
 
89
143
  /** A delivered question awaiting its retirement push. */
@@ -127,6 +181,8 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
127
181
  const ORPHAN_QUEUE_CAP = 50
128
182
 
129
183
  export interface PendingQuestion {
184
+ /** Stable local identity across racing state writers and submit recovery. */
185
+ question_id?: string
130
186
  /** One-line summary: the single question's text, or the set's first. */
131
187
  question: string
132
188
  /**
@@ -147,69 +203,75 @@ export interface PendingQuestion {
147
203
  collapse_key?: string
148
204
  /** Exact fanout of the live question; routing config may change afterwards. */
149
205
  device_ids?: string[]
206
+ /** Absolute end of the server reply window and its local continuation owner. */
207
+ reply_deadline_at?: number
208
+ /** Frozen before the first network byte, so an ambiguous submit is replayable. */
209
+ submission?: PendingSubmissionIntent
150
210
  }
151
211
 
152
- /** How often the grace window rechecks whether the user has come back. */
153
- const GRACE_POLL_MS = 5_000
212
+ interface PendingSubmissionIntent {
213
+ request_id: string
214
+ idempotency_key: string
215
+ collapse_key: string
216
+ device_ids: string[]
217
+ draft: NotificationDraftT
218
+ owner_deadline_at: number
219
+ }
220
+
221
+ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
222
+ ids: string[]
223
+ display: string
224
+ } {
225
+ const ids = entries.flatMap((entry) =>
226
+ entry.request_id === undefined ? [] : [entry.request_id],
227
+ )
228
+ return { ids, display: ids.join(', ') }
229
+ }
154
230
 
155
231
  /**
156
- * Total seconds a Stop hook may spend blocking. Both harnesses kill a command
157
- * hook at 600s, and a killed hook loses an answer the user has already given,
158
- * so the grace window yields to the reply wait rather than the other way round.
232
+ * The waiter's whole wall clock: the terminal-first timer, submission, and the
233
+ * wait for an answer, from the moment the turn-end hook starts.
234
+ *
235
+ * One plain ceiling, deliberately not a budget carved into reserves. The
236
+ * reserves existed to fit every phase inside a *held* turn; on Claude Code the
237
+ * turn is not held any more, and on a host that still holds one the useful
238
+ * question is only how long that host is willing to wait in total.
159
239
  */
160
- const STOP_BUDGET_SECONDS = 480
240
+ export const WAITER_CEILING_SECONDS = 480
161
241
 
162
- export type GraceOutcome =
163
- /** The window elapsed with the user still gone; escalate. */
164
- | 'absent'
165
- /** The user touched the machine; the terminal is theirs. */
166
- | 'user-returned'
167
- /** No idle source, so waiting would hold a terminal we cannot monitor. */
168
- | 'no-signal'
242
+ /**
243
+ * The wire contract's shortest reply window. A question submitted with less
244
+ * than this is rejected outright, and accepting one would in any case let the
245
+ * server go on taking an answer after the waiter that owns it has returned.
246
+ */
247
+ const MIN_REPLY_WINDOW_SECONDS = 60
169
248
 
170
249
  /**
171
250
  * The terminal-first wait: the question sits in the terminal for
172
251
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
173
252
  * devices.
174
253
  *
175
- * Holding a blocking Stop hook open is normally hostile — while it blocks, the
176
- * harness cannot show its prompt either, so a user wanting to answer locally is
177
- * locked out. What makes it safe is that the wait is abandoned the moment the
178
- * user touches the keyboard or mouse. The block therefore only ever persists
179
- * while they are demonstrably not using the machine, which costs them nothing.
254
+ * A plain timer, and nothing else. It once polled an idle signal so it could
255
+ * abandon the wait the moment the user touched the keyboard — necessary while
256
+ * the wait blocked the terminal, because a user wanting to answer locally was
257
+ * otherwise locked out of their own prompt. Nothing is monopolised now, so
258
+ * there is nothing to watch for and no machine this cannot run on.
180
259
  *
181
- * That safety depends entirely on the idle signal, so with no idle source this
182
- * refuses to wait at all rather than holding a terminal it cannot monitor.
183
- *
184
- * With `require_idle` off the whole calculus changes: the user has said they
185
- * want the question to reach them whether or not they are at the keyboard, so
186
- * there is nothing to watch for and this becomes what its name always claimed —
187
- * a plain timer. It then works on machines with no idle source too.
260
+ * The window is measured from registration, not from the turn's end: a
261
+ * question the agent asked five minutes ago while it kept working has already
262
+ * served its wait. A stamp from the future would otherwise consume the whole
263
+ * ceiling and leave no room to answer, so it starts no later than now.
188
264
  */
189
- export async function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome> {
190
- const threshold = ctx.config.away_after_seconds.value
191
- // Never let the grace window eat the reply wait's share of the hook budget.
192
- const graceSeconds = Math.min(
193
- ctx.config.ask_grace_seconds.value,
194
- Math.max(0, STOP_BUDGET_SECONDS - ctx.config.hook_reply_timeout_seconds.value),
195
- )
196
- // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
197
- // future would wait far past the hook's budget, one from the distant past
198
- // would skip the terminal-first window the user asked for entirely. Anything
199
- // outside a plausible range restarts the window from now.
200
- const elapsed = ctx.now() - askedAt
201
- const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now()
202
- const deadline = start + graceSeconds * 1000
203
-
204
- for (;;) {
205
- if (ctx.config.require_idle.value) {
206
- const idle = ctx.idleSeconds()
207
- if (idle === null) return 'no-signal'
208
- if (idle < threshold) return 'user-returned'
209
- }
210
- if (ctx.now() >= deadline) return 'absent'
211
- await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())))
212
- }
265
+ async function awaitTerminalFirstWindow(
266
+ ctx: HookContext,
267
+ askedAt: number,
268
+ ceilingAt: number,
269
+ ): Promise<void> {
270
+ const graceSeconds = ctx.config.ask_grace_seconds.value
271
+ if (graceSeconds <= 0) return
272
+ const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt)
273
+ const remaining = until - ctx.now()
274
+ if (remaining > 0) await ctx.sleep(remaining)
213
275
  }
214
276
 
215
277
  export interface HookOutcome {
@@ -217,6 +279,61 @@ export interface HookOutcome {
217
279
  stdout?: string
218
280
  /** Diagnostics; harnesses surface hook stderr in the transcript. */
219
281
  notes: string[]
282
+ /** Structured lifecycle detail that belongs on hook.end without user text. */
283
+ log?: Record<string, unknown>
284
+ }
285
+
286
+ /** An accepted continuation ready for whichever host owns the last meter. */
287
+ export interface ContinuationEvent {
288
+ context: string
289
+ answers: number
290
+ remaining: number
291
+ request_ids: string[]
292
+ journal_recorded_at: number
293
+ }
294
+
295
+ /**
296
+ * How much a route's own return proves about where the answer ended up.
297
+ *
298
+ * Acknowledgement belongs to the delivery, not to the harness envelope: routes
299
+ * end in different places, so no single field on a hook payload can speak for
300
+ * all of them. `stop_hook_active` is the harness confirming that a *blocking
301
+ * continuation* was admitted; it stays false for ever on a route that starts a
302
+ * brand-new turn instead of continuing this one, so a journal keyed to it alone
303
+ * never settles and every later turn-end redelivers the same answer.
304
+ *
305
+ * - `delivered` — the route completed a write to the harness itself (an inbox
306
+ * socket, a cold resume). Delivery is not consumption: the write proves the
307
+ * harness accepted the message, never that the model acted on it. But nothing
308
+ * later will ever prove more, and redelivering an answer without end is
309
+ * strictly worse than settling on the write, so the journal settles here.
310
+ * - `stdout` — the answer is this process's stdout, which the harness reads only
311
+ * after this process exits. Nothing this process can write is proof, so the
312
+ * journal waits for the successor Stop's `stop_hook_active`, and a crash
313
+ * before stdout replays the answer instead of losing it.
314
+ * - `held` — the route handed nothing over; the journal replays the answer.
315
+ */
316
+ export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
317
+
318
+ /** What a route returns: the hook's outcome plus what the attempt proved. */
319
+ export interface DeliveryOutcome {
320
+ stdout?: string
321
+ notes?: string[]
322
+ log?: Record<string, unknown>
323
+ acknowledgement: DeliveryAcknowledgement
324
+ }
325
+
326
+ /** Host adapter injected into the waiter; no route is implemented by the waiter. */
327
+ export interface EscalationDeliveryRoute {
328
+ kind: Exclude<DeliveryRoute, 'unsupported'>
329
+ deliver(event: ContinuationEvent): Promise<DeliveryOutcome>
330
+ }
331
+
332
+ export interface EscalationWaiterOptions {
333
+ sessionId: string
334
+ envelope: HookEnvelope
335
+ route: EscalationDeliveryRoute
336
+ processDeadlineAt?: number
220
337
  }
221
338
 
222
339
  function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
@@ -272,55 +389,6 @@ function updateSessionState(
272
389
  })
273
390
  }
274
391
 
275
- /**
276
- * Serialize a short read-modify-write without ever exposing partial JSON.
277
- * A crashed holder is recoverable after 30 seconds; releases compare their
278
- * random token under the same cooperating lock discipline before unlinking.
279
- */
280
- const FILE_LOCK_STALE_MS = 30_000
281
- const FILE_LOCK_WAIT_MS = 1_000
282
- const FILE_LOCK_POLL_MS = 5
283
- const lockSleep = new Int32Array(new SharedArrayBuffer(4))
284
-
285
- function withFileLock<T>(file: string, action: () => T): T {
286
- mkdirSync(path.dirname(file), { recursive: true })
287
- const token = randomBytes(12).toString('base64url')
288
- const deadline = Date.now() + FILE_LOCK_WAIT_MS
289
- for (;;) {
290
- try {
291
- const handle = openSync(file, 'wx', 0o600)
292
- try {
293
- writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
294
- } finally {
295
- closeSync(handle)
296
- }
297
- break
298
- } catch (err) {
299
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
300
- let stale = false
301
- try {
302
- const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
303
- stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
304
- } catch {
305
- stale = true
306
- }
307
- if (stale) rmSync(file, { force: true })
308
- else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
309
- else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
310
- }
311
- }
312
- try {
313
- return action()
314
- } finally {
315
- try {
316
- const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
317
- if (held.token === token) rmSync(file, { force: true })
318
- } catch {
319
- // A replaced or externally removed lock is no longer ours to release.
320
- }
321
- }
322
- }
323
-
324
392
  /**
325
393
  * Session state a crashed harness left behind.
326
394
  *
@@ -384,10 +452,11 @@ export function pruneAbandonedSessions(
384
452
  * Exclusive create is atomic on POSIX, so exactly one process gets the claim
385
453
  * and the other steps aside. A short guard serializes stale replacement with
386
454
  * contenders and releases; random ownership tokens keep an old holder from
387
- * unlinking its replacement. A claim older than the hook budget is a crashed
388
- * process rather than a live one and is broken, avoiding permanent suppression.
455
+ * unlinking its replacement. A live PID owns the claim regardless of age; a
456
+ * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
457
+ * no trustworthy PID fall back to an age limit.
389
458
  */
390
- const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
459
+ const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
391
460
  const heldClaims = new Map<string, string>()
392
461
 
393
462
  export function claimQuestionPush(
@@ -412,9 +481,13 @@ export function claimQuestionPush(
412
481
  } catch {
413
482
  // Held. Break it only if whoever holds it cannot still be running.
414
483
  try {
415
- const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
484
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown; pid?: unknown }
416
485
  const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
417
- if (age >= 0 && age < CLAIM_TTL_MS) return false
486
+ if (typeof held.pid === 'number') {
487
+ if (processIsAlive(held.pid)) return false
488
+ } else if (age >= 0 && age < CLAIM_TTL_MS) {
489
+ return false
490
+ }
418
491
  } catch {
419
492
  // Unreadable or corrupt: treat as abandoned.
420
493
  }
@@ -436,6 +509,15 @@ export function claimQuestionPush(
436
509
  }
437
510
  }
438
511
 
512
+ function processIsAlive(pid: number): boolean {
513
+ try {
514
+ process.kill(pid, 0)
515
+ return true
516
+ } catch (err) {
517
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
518
+ }
519
+ }
520
+
439
521
  export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
440
522
  const file = claimPath(sessionId, env)
441
523
  const token = heldClaims.get(file)
@@ -490,7 +572,11 @@ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
490
572
  */
491
573
  const MAX_STORED_QUESTION_CHARS = 2000
492
574
 
493
- /** Records which session is working in a directory, for `notifai ask`. */
575
+ interface StoredProjectSessionPointer extends ProjectSessionPointer {
576
+ updatedAt: number
577
+ }
578
+
579
+ /** Records every live session working in a directory, for `notifai ask`. */
494
580
  export function writeProjectSession(
495
581
  cwd: string,
496
582
  env: NodeJS.ProcessEnv,
@@ -500,9 +586,21 @@ export function writeProjectSession(
500
586
  ): void {
501
587
  const file = projectSessionPointerPath(cwd, env)
502
588
  withFileLock(`${file}.lock`, () => {
589
+ const existing = readStoredProjectSessionPointers(file).filter(
590
+ (entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000,
591
+ )
503
592
  atomicWriteFileSync(
504
593
  file,
505
- `${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`,
594
+ `${JSON.stringify({
595
+ sessions: [
596
+ ...existing.map((entry) => ({
597
+ session_id: entry.sessionId,
598
+ updated_at: entry.updatedAt,
599
+ harness: entry.harness,
600
+ })),
601
+ { session_id: sessionId, updated_at: now, harness },
602
+ ],
603
+ })}\n`,
506
604
  )
507
605
  })
508
606
  }
@@ -512,6 +610,34 @@ export interface ProjectSessionPointer {
512
610
  harness: HookHarness
513
611
  }
514
612
 
613
+ function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
614
+ if (!existsSync(file)) return []
615
+ try {
616
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { sessions?: unknown }
617
+ if (!Array.isArray(parsed.sessions)) return []
618
+ return parsed.sessions.flatMap((candidate): StoredProjectSessionPointer[] => {
619
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return []
620
+ const entry = candidate as {
621
+ session_id?: unknown
622
+ updated_at?: unknown
623
+ harness?: unknown
624
+ }
625
+ if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
626
+ if (typeof entry.updated_at !== 'number') return []
627
+ if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
628
+ return [
629
+ {
630
+ sessionId: entry.session_id,
631
+ updatedAt: entry.updated_at,
632
+ harness: entry.harness as HookHarness,
633
+ },
634
+ ]
635
+ })
636
+ } catch {
637
+ return []
638
+ }
639
+ }
640
+
515
641
  /**
516
642
  * Resolves the session working in `cwd`. Stale pointers are ignored rather than
517
643
  * trusted: an id left by a session that ended days ago would send the question
@@ -526,37 +652,71 @@ export function readProjectSession(
526
652
  return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
527
653
  }
528
654
 
655
+ /** A live pointer keeps when it was last written, so callers can rank by it. */
656
+ export interface LiveProjectSessionPointer extends ProjectSessionPointer {
657
+ updatedAt: number
658
+ }
659
+
660
+ /**
661
+ * Every session that has fired a hook in this directory recently, newest first.
662
+ *
663
+ * This is the only local record of which harness sessions are actually alive
664
+ * here, which makes it the tiebreaker when the environment alone cannot say
665
+ * which harness owns the current shell.
666
+ */
667
+ export function readLiveProjectSessionPointers(
668
+ cwd: string,
669
+ env: NodeJS.ProcessEnv,
670
+ now: number,
671
+ maxAgeMs = 24 * 3600 * 1000,
672
+ ): LiveProjectSessionPointer[] {
673
+ const file = projectSessionPointerPath(cwd, env)
674
+ return readStoredProjectSessionPointers(file)
675
+ .filter((entry) => now - entry.updatedAt <= maxAgeMs)
676
+ .filter((entry) => {
677
+ try {
678
+ // A pointer is routing evidence only while its session state still
679
+ // exists and parses. SessionEnd and explicit cleanup therefore
680
+ // invalidate it even when a crash prevented removal from this index.
681
+ const sessionFile = sessionStatePath(entry.sessionId, env)
682
+ if (!existsSync(sessionFile)) return false
683
+ const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
684
+ return typeof state === 'object' && state !== null && !Array.isArray(state)
685
+ } catch {
686
+ return false
687
+ }
688
+ })
689
+ .sort((left, right) => right.updatedAt - left.updatedAt)
690
+ .map((entry) => ({
691
+ sessionId: entry.sessionId,
692
+ harness: entry.harness,
693
+ updatedAt: entry.updatedAt,
694
+ }))
695
+ }
696
+
529
697
  export function readProjectSessionPointer(
530
698
  cwd: string,
531
699
  env: NodeJS.ProcessEnv,
532
700
  now: number,
533
701
  maxAgeMs = 24 * 3600 * 1000,
534
702
  ): ProjectSessionPointer | null {
535
- const file = projectSessionPointerPath(cwd, env)
536
- if (!existsSync(file)) return null
537
- try {
538
- const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
539
- session_id?: unknown
540
- updated_at?: unknown
541
- harness?: unknown
542
- }
543
- if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
544
- if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
545
- if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
546
- // A pointer is routing evidence only while its session state still exists
547
- // and parses. SessionEnd and explicit cleanup therefore invalidate it even
548
- // when a crash prevented the pointer file itself from being removed.
549
- const sessionFile = sessionStatePath(parsed.session_id, env)
550
- if (!existsSync(sessionFile)) return null
551
- const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
552
- if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
553
- return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
554
- } catch {
555
- return null
556
- }
703
+ const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0]
704
+ return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
557
705
  }
558
706
 
559
- const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
707
+ export function readMatchingProjectSessionPointer(
708
+ cwd: string,
709
+ env: NodeJS.ProcessEnv,
710
+ now: number,
711
+ sessionId: string,
712
+ harness: HookHarness,
713
+ maxAgeMs = 24 * 3600 * 1000,
714
+ ): ProjectSessionPointer | null {
715
+ const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find(
716
+ (entry) => entry.sessionId === sessionId && entry.harness === harness,
717
+ )
718
+ return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness }
719
+ }
560
720
 
561
721
  function clearMatchingProjectSession(
562
722
  cwd: string,
@@ -565,75 +725,31 @@ function clearMatchingProjectSession(
565
725
  ): void {
566
726
  const file = projectSessionPointerPath(cwd, env)
567
727
  withFileLock(`${file}.lock`, () => {
568
- try {
569
- const parsed = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: unknown }
570
- if (parsed.session_id === sessionId) rmSync(file, { force: true })
571
- } catch {
572
- // Missing/corrupt is already not a live pointer.
728
+ const remaining = readStoredProjectSessionPointers(file).filter(
729
+ (entry) => entry.sessionId !== sessionId,
730
+ )
731
+ if (remaining.length === 0) {
732
+ rmSync(file, { force: true })
733
+ return
573
734
  }
735
+ atomicWriteFileSync(
736
+ file,
737
+ `${JSON.stringify({
738
+ sessions: remaining.map((entry) => ({
739
+ session_id: entry.sessionId,
740
+ updated_at: entry.updatedAt,
741
+ harness: entry.harness,
742
+ })),
743
+ })}\n`,
744
+ )
574
745
  })
575
746
  }
576
747
 
577
- /**
578
- * Absent means the user is not at this machine's keyboard.
579
- *
580
- * OS idle time answers that directly, so where it exists it decides alone.
581
- * Silence since the user's last prompt is only ever a proxy for it, and a poor
582
- * one — it is wrong in both directions:
583
- *
584
- * - Too long: it counts the agent's own turn, so a user watching a build was
585
- * read as absent and had the question pushed at them.
586
- * - Too short: a session that has just been spawned always has a fresh
587
- * prompt, so its FIRST question could never escalate however long its
588
- * owner had been gone. That is the "kick off some agents and
589
- * walk away" case this feature mainly exists for, and requiring both
590
- * signals to agree is what broke it.
591
- *
592
- * The proxy therefore survives only as the fallback for machines with no idle
593
- * source. There, never having seen a prompt is not evidence of absence — it is
594
- * a missing `UserPromptSubmit` hook — so it resolves to "present".
595
- *
596
- * Answering from a companion device does not make the user present; only touching this
597
- * machine does. Answering on a device is evidence of being away from it.
598
- *
599
- * All of which only matters if presence is being consulted at all. With
600
- * `require_idle` off the user has said their whereabouts are not a
601
- * precondition, so this stops guessing and answers yes.
602
- */
603
- export function isUserAway(
604
- state: SessionState,
605
- config: CliConfig,
606
- now: number,
607
- idleSeconds: number | null,
608
- ): boolean {
609
- if (!config.require_idle.value) return true
610
- const threshold = config.away_after_seconds.value
611
- if (idleSeconds !== null) return idleSeconds >= threshold
612
- if (state.last_prompt_at === undefined) return false
613
- const silence = now - state.last_prompt_at
614
- // Both signs of a clock jump produce a wrong answer here, and the fallback
615
- // path has no monotonic reference to check against: a forward jump (NTP
616
- // correction, VM resume) hijacks a terminal whose user is sitting right
617
- // there, and a backward one suppresses escalation for someone genuinely
618
- // gone. Neither delta is evidence of anything, so it resolves the way every
619
- // other absence of evidence does — present.
620
- if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS) return false
621
- return silence >= threshold * 1000
622
- }
623
-
624
- /**
625
- * Beyond this, silence says more about the clock or an abandoned session file
626
- * than about the user. Matches the project pointer's staleness horizon.
627
- */
628
- const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000
629
-
630
748
  export interface HookContext {
631
749
  client: ApiClient
632
750
  config: CliConfig
633
751
  env: NodeJS.ProcessEnv
634
752
  now: () => number
635
- /** Seconds since the last keyboard/mouse event, or null if unknowable. */
636
- idleSeconds: () => number | null
637
753
  /** Injected so tests advance a virtual clock instead of sleeping. */
638
754
  sleep: (milliseconds: number) => Promise<void>
639
755
  /** Bounded wait for the first reply; injected so tests do not sleep. */
@@ -641,21 +757,54 @@ export interface HookContext {
641
757
  requestId: string,
642
758
  timeoutSeconds: number,
643
759
  ) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
644
- /** OpenCode cannot consume Stop stdout, so its adapter must never wait there. */
760
+ /** The active harness selects the native continuation output adapter. */
645
761
  harness?: HookHarness
762
+ /**
763
+ * The local record. A hook's decisions are invisible from everywhere else —
764
+ * its stderr belongs to the harness and its usual outcome is to do nothing —
765
+ * so this is the only account of why a question did or did not travel.
766
+ */
767
+ log?: Logger
646
768
  }
647
769
 
648
- export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
649
-
650
- interface SubmittedQuestion {
651
- requestId: string
652
- collapseKey: string
653
- /** The devices the question went to; retirement must not reach any other. */
654
- devices: string[]
770
+ /**
771
+ * Record one gate decision.
772
+ *
773
+ * `verdict` is what happened to the question and `reason` is the closed-set
774
+ * name of the rule that decided it, so an agent can ask "why was it held" with
775
+ * a filter rather than by matching on prose. The prose still exists — it is what
776
+ * the user sees in the terminal — but it is free to be rewritten, and a log a
777
+ * filter cannot rely on is a log nobody filters.
778
+ */
779
+ export type GateReason =
780
+ | 'no-session'
781
+ | 'no-question'
782
+ | 'answered'
783
+ | 'already-asked'
784
+ | 'continuation-repeat'
785
+ | 'continuation-limit'
786
+ | 'delivery-limit'
787
+ | 'notifications-off'
788
+ | 'claimed-elsewhere'
789
+ | 'no-devices'
790
+ | 'proceeding'
791
+
792
+ function gate(
793
+ ctx: HookContext,
794
+ verdict: 'held' | 'proceeding',
795
+ reason: GateReason,
796
+ data: Record<string, unknown> = {},
797
+ ): void {
798
+ ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
655
799
  }
656
800
 
657
- /** Re-check local presence at this cadence while a pushed question is waiting. */
658
- const REPLY_PRESENCE_POLL_SECONDS = 5
801
+ export type HookHarness = Harness
802
+
803
+ /**
804
+ * One round of the waiter's reply poll. Short enough that several questions
805
+ * still share one wall clock, long enough not to hammer the server.
806
+ */
807
+ const REPLY_POLL_SECONDS = 5
659
808
 
660
809
  /**
661
810
  * Push a question and block for the answer.
@@ -672,7 +821,7 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
672
821
  * Put one registered question on the user's devices. Submission only — the
673
822
  * wait happens once, across every live question, in the caller.
674
823
  */
675
- async function submitQuestion(
824
+ async function prepareQuestionSubmission(
676
825
  ctx: HookContext,
677
826
  options: {
678
827
  title: string
@@ -684,8 +833,10 @@ async function submitQuestion(
684
833
  session?: string | undefined
685
834
  /** How long the server keeps accepting an answer. */
686
835
  windowSeconds: number
836
+ /** Absolute owner deadline persisted before submit. */
837
+ ownerDeadlineAt: number
687
838
  },
688
- ): Promise<SubmittedQuestion | { error: string }> {
839
+ ): Promise<PendingSubmissionIntent | { error: string }> {
689
840
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
690
841
  // A draft carrying `reply` is rejected outright if it targets a device that
691
842
  // cannot answer, so resolve the healthy companion platforms explicitly.
@@ -708,63 +859,112 @@ async function submitQuestion(
708
859
  })
709
860
  if (!build.ok) return { error: build.error }
710
861
 
862
+ return {
863
+ request_id: `req_${randomBytes(18).toString('base64url')}`,
864
+ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
865
+ collapse_key: collapseKey,
866
+ device_ids: answerable,
867
+ draft: build.draft,
868
+ owner_deadline_at: options.ownerDeadlineAt,
869
+ }
870
+ }
871
+
872
+ async function submitQuestion(
873
+ ctx: HookContext,
874
+ intent: PendingSubmissionIntent,
875
+ ): Promise<SubmissionReceipt> {
711
876
  const receipt = await ctx.client.submit(
712
- { idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
877
+ {
878
+ request_id: intent.request_id,
879
+ idempotency_key: intent.idempotency_key,
880
+ draft: intent.draft,
881
+ },
713
882
  0,
714
883
  )
715
- return { requestId: receipt.request_id, collapseKey, devices: answerable }
884
+ if (receipt.request_id !== intent.request_id) {
885
+ throw new Error(
886
+ `server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`,
887
+ )
888
+ }
889
+ return receipt
716
890
  }
717
891
 
718
892
  /**
719
- * One blocking wait across every live question. Each round polls all of them
720
- * concurrently, so several questions cost the same wall clock as one; the
721
- * first round that finds any reply returns everything found in that round, and
722
- * whatever was not answered stays registered.
893
+ * The waiter's wait: one bounded poll across every live question at once.
894
+ *
895
+ * Each round polls all of them concurrently, so several questions cost the
896
+ * same wall clock as one; the first round that finds any reply returns
897
+ * everything found in that round, and whatever was not answered stays
898
+ * registered. A question whose polling is permanently rejected drops out of
899
+ * the round rather than failing the whole wait.
723
900
  */
724
- async function waitForAnyReplyWhileAway(
901
+ async function waitForAnyReply(
725
902
  ctx: HookContext,
726
903
  requestIds: string[],
727
904
  timeoutSeconds: number,
728
- ): Promise<{ byRequest: Map<string, ReplyView[]>; degraded: boolean; userReturned: boolean }> {
905
+ ): Promise<{
906
+ byRequest: Map<string, ReplyView[]>
907
+ degraded: boolean
908
+ permanentFailures: Map<string, string>
909
+ }> {
729
910
  const deadline = ctx.now() + timeoutSeconds * 1000
730
911
  let degraded = false
731
912
  let firstPoll = true
913
+ const permanentFailures = new Map<string, string>()
732
914
 
733
915
  for (;;) {
734
- if (ctx.config.require_idle.value) {
735
- const idle = ctx.idleSeconds()
736
- if (idle !== null && idle < ctx.config.away_after_seconds.value) {
737
- return { byRequest: new Map(), degraded, userReturned: true }
738
- }
739
- }
740
-
741
916
  const remainingMs = Math.max(0, deadline - ctx.now())
742
917
  if (!firstPoll && remainingMs === 0) {
743
- return { byRequest: new Map(), degraded, userReturned: false }
918
+ return { byRequest: new Map(), degraded, permanentFailures }
744
919
  }
745
920
  firstPoll = false
746
- const pollSeconds = Math.min(
747
- REPLY_PRESENCE_POLL_SECONDS,
748
- Math.max(0, Math.ceil(remainingMs / 1000)),
749
- )
921
+ const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
750
922
  const results = await Promise.all(
751
- requestIds.map(async (requestId) => {
752
- try {
753
- return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
754
- } catch {
755
- return { requestId, replies: [] as ReplyView[], degraded: true }
756
- }
757
- }),
923
+ requestIds
924
+ .filter((requestId) => !permanentFailures.has(requestId))
925
+ .map(async (requestId) => {
926
+ try {
927
+ return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
928
+ } catch (err) {
929
+ if (isRetryableReplyPollError(err)) {
930
+ return { requestId, replies: [] as ReplyView[], degraded: true }
931
+ }
932
+ return {
933
+ requestId,
934
+ replies: [] as ReplyView[],
935
+ degraded: false,
936
+ permanentFailure: replyPollFailure(err),
937
+ }
938
+ }
939
+ }),
758
940
  )
759
941
  const byRequest = new Map<string, ReplyView[]>()
760
942
  for (const result of results) {
761
943
  degraded ||= result.degraded === true
944
+ if (result.permanentFailure !== undefined) {
945
+ permanentFailures.set(result.requestId, result.permanentFailure)
946
+ }
762
947
  if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
763
948
  }
764
- if (byRequest.size > 0) return { byRequest, degraded, userReturned: false }
949
+ if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
950
+ return { byRequest, degraded, permanentFailures }
951
+ }
765
952
  }
766
953
  }
767
954
 
955
+ /** A non-retryable replies fault, with enough server identity to act on it. */
956
+ function replyPollFailure(err: unknown): string {
957
+ return err instanceof ApiCallError
958
+ ? `${err.code} (HTTP ${err.status}): ${err.message}`
959
+ : String(err)
960
+ }
961
+
962
+ function permanentReplyFailureNote(failures: Map<string, string>): string | null {
963
+ if (failures.size === 0) return null
964
+ const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ')
965
+ return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
966
+ }
967
+
768
968
  /**
769
969
  * Healthy companion devices that implement replies. Both the iOS app and the
770
970
  * macOS app register reply categories and submit answers directly.
@@ -806,15 +1006,28 @@ function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefi
806
1006
  */
807
1007
  export type RetireDeps = Pick<HookContext, 'client' | 'config'>
808
1008
 
809
- /** Best effort: a question that outlives its hook is a nuisance, not a failure. */
810
- async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
1009
+ /**
1010
+ * Close is a server-side serialization fence: a successful response contains
1011
+ * every reply that committed before the window closed. `null` is deliberately
1012
+ * different from an empty reply set — it means ownership could not be proven
1013
+ * final and the local record must stay recoverable.
1014
+ */
1015
+ async function finalizeReplies(
1016
+ ctx: RetireDeps,
1017
+ requestId: string,
1018
+ ): Promise<ListRepliesResponse | null> {
811
1019
  try {
812
- await ctx.client.closeReplies(requestId)
1020
+ return await ctx.client.closeReplies(requestId)
813
1021
  } catch {
814
- // The window expires on its own; nothing here is worth failing a hook for.
1022
+ return null
815
1023
  }
816
1024
  }
817
1025
 
1026
+ /** Best effort only for already-journaled retirement debt. */
1027
+ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
1028
+ await finalizeReplies(ctx, requestId)
1029
+ }
1030
+
818
1031
  /**
819
1032
  * Retire the question on every device it reached. A state change is not news
820
1033
  * (D-B): the retirement rides as a background state sync — no alert, no
@@ -1078,7 +1291,15 @@ export async function drainOrphanRetirements(
1078
1291
 
1079
1292
  const LATE_STOP_POLL_SECONDS = 4
1080
1293
  const LATE_PROMPT_POLL_SECONDS = 3
1081
- const MAX_CONTINUATION_COUNT = 3
1294
+ /**
1295
+ * The one bound on answer deliveries, for every route.
1296
+ *
1297
+ * It caps two things that both used to be able to run away: how many times a
1298
+ * single accepted answer may be handed to a route before the journal gives up
1299
+ * on it, and how many answer-driven continuation generations one chain may
1300
+ * produce.
1301
+ */
1302
+ export const MAX_CONTINUATION_COUNT = 3
1082
1303
 
1083
1304
  interface PendingPoll {
1084
1305
  /** The answer to act on: the latest reply, because a later one corrects. */
@@ -1086,7 +1307,7 @@ interface PendingPoll {
1086
1307
  /** Every reply in arrival order, for free-text answers given in parts. */
1087
1308
  replies: ReplyView[]
1088
1309
  degraded: boolean
1089
- failed: boolean
1310
+ permanentFailure: string | null
1090
1311
  }
1091
1312
 
1092
1313
  /** One bounded recovery poll for a question that outlived its original wait. */
@@ -1096,7 +1317,7 @@ async function pollPendingReply(
1096
1317
  timeoutSeconds: number,
1097
1318
  ): Promise<PendingPoll> {
1098
1319
  if (pending.request_id === undefined) {
1099
- return { reply: null, replies: [], degraded: false, failed: false }
1320
+ return { reply: null, replies: [], degraded: false, permanentFailure: null }
1100
1321
  }
1101
1322
  try {
1102
1323
  const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
@@ -1104,10 +1325,12 @@ async function pollPendingReply(
1104
1325
  reply: result.replies.at(-1) ?? null,
1105
1326
  replies: result.replies,
1106
1327
  degraded: result.degraded === true,
1107
- failed: false,
1328
+ permanentFailure: null,
1108
1329
  }
1109
- } catch {
1110
- return { reply: null, replies: [], degraded: true, failed: true }
1330
+ } catch (err) {
1331
+ return isRetryableReplyPollError(err)
1332
+ ? { reply: null, replies: [], degraded: true, permanentFailure: null }
1333
+ : { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) }
1111
1334
  }
1112
1335
  }
1113
1336
 
@@ -1118,6 +1341,49 @@ interface AnsweredPending {
1118
1341
  replies: ReplyView[]
1119
1342
  }
1120
1343
 
1344
+ interface FinalizedPending {
1345
+ pending: PendingQuestion
1346
+ response: ListRepliesResponse | null
1347
+ }
1348
+
1349
+ /** Close several windows concurrently without confusing failure with silence. */
1350
+ async function finalizePendings(
1351
+ ctx: HookContext,
1352
+ pending: PendingQuestion[],
1353
+ ): Promise<FinalizedPending[]> {
1354
+ return Promise.all(
1355
+ pending.map(async (entry) => ({
1356
+ pending: entry,
1357
+ response: await finalizeReplies(ctx, entry.request_id!),
1358
+ })),
1359
+ )
1360
+ }
1361
+
1362
+ /** Convert a fenced final reply set into the exact answer handed to the model. */
1363
+ function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1364
+ const replies = finalized.response?.replies ?? []
1365
+ const reply = replies.at(-1)
1366
+ return reply === undefined ? null : { pending: finalized.pending, reply, replies }
1367
+ }
1368
+
1369
+ /** Persist and surface one answer without letting the two representations drift. */
1370
+ function reportAnswer(
1371
+ ctx: HookContext,
1372
+ notes: string[],
1373
+ answered: AnsweredPending,
1374
+ late: boolean,
1375
+ ): void {
1376
+ ctx.log?.info('hook.answer', {
1377
+ answered: true,
1378
+ stage: 'queued',
1379
+ ...(late ? { late: true } : {}),
1380
+ request_id: answered.pending.request_id,
1381
+ device: answered.reply.device_name,
1382
+ text: answered.reply.text,
1383
+ })
1384
+ notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
1385
+ }
1386
+
1121
1387
  /**
1122
1388
  * One bounded recovery poll across every delivered question, concurrently —
1123
1389
  * N questions must not multiply the hook's latency budget by N.
@@ -1126,19 +1392,27 @@ async function pollPendingReplies(
1126
1392
  ctx: HookContext,
1127
1393
  live: PendingQuestion[],
1128
1394
  timeoutSeconds: number,
1129
- ): Promise<{ answered: AnsweredPending[]; troubled: boolean }> {
1395
+ ): Promise<{
1396
+ answered: AnsweredPending[]
1397
+ transientTrouble: boolean
1398
+ permanentFailures: Map<string, string>
1399
+ }> {
1130
1400
  const polls = await Promise.all(
1131
1401
  live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
1132
1402
  )
1133
1403
  const answered: AnsweredPending[] = []
1134
- let troubled = false
1404
+ let transientTrouble = false
1405
+ const permanentFailures = new Map<string, string>()
1135
1406
  for (const [index, poll] of polls.entries()) {
1136
1407
  if (poll.reply !== null) {
1137
1408
  answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
1138
1409
  }
1139
- if (poll.failed || poll.degraded) troubled = true
1410
+ if (poll.degraded) transientTrouble = true
1411
+ if (poll.permanentFailure !== null) {
1412
+ permanentFailures.set(live[index]!.request_id!, poll.permanentFailure)
1413
+ }
1140
1414
  }
1141
- return { answered, troubled }
1415
+ return { answered, transientTrouble, permanentFailures }
1142
1416
  }
1143
1417
 
1144
1418
  /** The registered-question queue. Anything but the current shape reads as empty. */
@@ -1148,6 +1422,9 @@ function pendingList(state: SessionState): PendingQuestion[] {
1148
1422
 
1149
1423
  /** Registration identity — the convention every racing writer compares by. */
1150
1424
  function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1425
+ if (a.question_id !== undefined || b.question_id !== undefined) {
1426
+ return a.question_id !== undefined && a.question_id === b.question_id
1427
+ }
1151
1428
  return a.question === b.question && a.asked_at === b.asked_at
1152
1429
  }
1153
1430
 
@@ -1167,18 +1444,28 @@ function pendingHasChoices(pending: PendingQuestion): boolean {
1167
1444
  * it. Free-text answers can arrive in parts, and every part reaches the
1168
1445
  * agent in the order it was written, so it can tell expansion from
1169
1446
  * correction itself.
1447
+ *
1448
+ * The continuation repeats only identity the agent actually registered and
1449
+ * the answer the server accepted. It never invents trust, urgency, permission,
1450
+ * or approval claims for the transport to assert.
1170
1451
  */
1171
- function answerContext(replies: ReplyView[], hadChoices: boolean): string {
1452
+ function answerContext(answered: AnsweredPending): string {
1453
+ const { pending, replies } = answered
1172
1454
  const latest = replies.at(-1)
1173
1455
  if (latest === undefined) return 'Notifai — no answer was recorded.'
1174
- if (replies.length === 1 || hadChoices) {
1175
- return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
1456
+ const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))]
1457
+ const identity =
1458
+ answeredQuestionIds.length === 0
1459
+ ? ''
1460
+ : `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `
1461
+ const question = `question ${JSON.stringify(pending.question)}`
1462
+ if (replies.length === 1 || pendingHasChoices(pending)) {
1463
+ return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`
1176
1464
  }
1177
- const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
1465
+ const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ')
1178
1466
  return (
1179
- `Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
1180
- `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
1181
- 'Continue with that answer.'
1467
+ `Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
1468
+ `in the order written: ${parts}. Later parts extend or correct earlier ones.`
1182
1469
  )
1183
1470
  }
1184
1471
 
@@ -1194,47 +1481,85 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
1194
1481
  ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1195
1482
  : ''
1196
1483
  if (answered.length === 1) {
1197
- const only = answered[0]!
1198
- return answerContext(only.replies, pendingHasChoices(only.pending)) + tail
1484
+ return answerContext(answered[0]!) + tail
1199
1485
  }
1200
1486
  const lines = answered.map(({ pending, replies }) => {
1201
1487
  const latest = replies.at(-1)!
1202
1488
  const answer =
1203
1489
  replies.length === 1 || pendingHasChoices(pending)
1204
- ? `"${latest.text}"`
1205
- : `${replies.map((reply) => `"${reply.text}"`).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
1206
- return `- "${pending.question}" → ${answer} (from ${latest.device_name})`
1490
+ ? JSON.stringify(latest.text)
1491
+ : `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
1492
+ const ids = [...new Set(latest.answers.map((entry) => entry.question_id))]
1493
+ const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
1494
+ return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
1207
1495
  })
1208
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`
1496
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`
1209
1497
  }
1210
1498
 
1211
- function userPromptAnswerOutput(context: string): string {
1499
+ function stopAnswerOutput(context: string): string {
1212
1500
  return JSON.stringify({
1213
- hookSpecificOutput: {
1214
- hookEventName: 'UserPromptSubmit',
1215
- additionalContext: context,
1216
- },
1501
+ decision: 'block',
1502
+ reason: context,
1217
1503
  })
1218
1504
  }
1219
1505
 
1220
- function stopAnswerOutput(context: string): string {
1221
- return JSON.stringify({ decision: 'block', reason: context })
1222
- }
1223
-
1224
1506
  /**
1225
1507
  * Close, truthfully retire, and forget every answered pending question in one
1226
1508
  * state write, without dropping any other retirement debt or any question
1227
1509
  * still waiting. Stop answers also open one bounded continuation generation;
1228
1510
  * UserPromptSubmit answers ride the user's new turn.
1229
1511
  */
1230
- async function finishAnsweredPendings(
1512
+ function stageAcceptedAnswers(
1231
1513
  ctx: HookContext,
1232
- envelope: HookEnvelope,
1233
1514
  sessionId: string,
1234
1515
  answered: AnsweredPending[],
1235
- continuation: boolean,
1236
- ): Promise<void> {
1237
- const retirements = answered
1516
+ remaining: number,
1517
+ ): AcceptedAnswerDelivery {
1518
+ const accepted: AcceptedAnswerDelivery = {
1519
+ answers: answered,
1520
+ remaining,
1521
+ recorded_at: ctx.now(),
1522
+ }
1523
+ updateSessionState(sessionId, ctx.env, (current) => {
1524
+ const pendingRemaining = pendingList(current).filter(
1525
+ (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1526
+ )
1527
+ const next: SessionState = {
1528
+ ...current,
1529
+ accepted,
1530
+ }
1531
+ if (pendingRemaining.length > 0) next.pending = pendingRemaining
1532
+ else delete next.pending
1533
+ return next
1534
+ })
1535
+ return accepted
1536
+ }
1537
+
1538
+ /** Amend the live accepted record in place, without resurrecting a settled one. */
1539
+ function amendAcceptedAnswers(
1540
+ ctx: HookContext,
1541
+ sessionId: string,
1542
+ amend: (accepted: AcceptedAnswerDelivery) => AcceptedAnswerDelivery,
1543
+ ): void {
1544
+ updateSessionState(sessionId, ctx.env, (current) =>
1545
+ current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) },
1546
+ )
1547
+ }
1548
+
1549
+ /**
1550
+ * Retire the answered questions and close the journal.
1551
+ *
1552
+ * Called once an answer's delivery is acknowledged — by the route's own write,
1553
+ * or by the successor Stop of a blocking continuation. The continuation counter
1554
+ * grows with each settled generation, so the cap that bounds chained
1555
+ * answer-to-question loops is a real count rather than a constant 1.
1556
+ */
1557
+ function settleAcceptedAnswers(
1558
+ ctx: HookContext,
1559
+ sessionId: string,
1560
+ accepted: AcceptedAnswerDelivery,
1561
+ ): void {
1562
+ const retirements = accepted.answers
1238
1563
  .map(({ pending }) => retiringQuestion(pending, 'answered'))
1239
1564
  .filter((entry): entry is RetiringQuestion => entry !== null)
1240
1565
  updateSessionState(sessionId, ctx.env, (current) => {
@@ -1244,24 +1569,44 @@ async function finishAnsweredPendings(
1244
1569
  if (existing < 0) retiring.push(retirement)
1245
1570
  else retiring[existing] = retirement
1246
1571
  }
1247
- const remaining = pendingList(current).filter(
1248
- (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1249
- )
1250
1572
  const next: SessionState = { ...current, retiring }
1251
- if (remaining.length > 0) next.pending = remaining
1252
- else delete next.pending
1253
- if (continuation) {
1254
- next.continuation = {
1255
- answered_at: ctx.now(),
1256
- count:
1257
- (envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
1573
+ delete next.accepted
1574
+ next.continuation = {
1575
+ answered_at: accepted.recorded_at,
1576
+ count: (current.continuation?.count ?? 0) + 1,
1577
+ }
1578
+ return next
1579
+ })
1580
+ }
1581
+
1582
+ async function retirePendings(
1583
+ ctx: HookContext,
1584
+ envelope: HookEnvelope,
1585
+ sessionId: string,
1586
+ entries: PendingQuestion[],
1587
+ state: LifecycleEndState,
1588
+ ): Promise<void> {
1589
+ updateSessionState(sessionId, ctx.env, (current) => {
1590
+ const retiring = [...(current.retiring ?? [])]
1591
+ for (const entry of entries) {
1592
+ const retirement = retiringQuestion(entry, state)
1593
+ if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1594
+ retiring.push(retirement)
1258
1595
  }
1259
- } else {
1260
- next.last_prompt_at = ctx.now()
1261
1596
  }
1597
+ const pending = pendingList(current).filter(
1598
+ (candidate) => !entries.some((entry) => isSamePending(candidate, entry)),
1599
+ )
1600
+ const next: SessionState = { ...current }
1601
+ if (pending.length > 0) next.pending = pending
1602
+ else delete next.pending
1603
+ if (retiring.length > 0) next.retiring = retiring
1604
+ else delete next.retiring
1262
1605
  return next
1263
1606
  })
1264
- await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1607
+ // Network retirement is deliberately deferred to a later no-question hook.
1608
+ // The state write above is the durable operation; blocking here creates a
1609
+ // crash window before an answer can reach harness stdout.
1265
1610
  }
1266
1611
 
1267
1612
  // ---------------------------------------------------------------------------
@@ -1283,27 +1628,67 @@ export async function handleUserPromptSubmit(
1283
1628
  if (!sessionId) return { notes }
1284
1629
 
1285
1630
  const state = readSessionState(sessionId, ctx.env)
1631
+ if (state.accepted !== undefined) {
1632
+ updateSessionState(sessionId, ctx.env, (current) => ({
1633
+ ...current,
1634
+ last_prompt_at: ctx.now(),
1635
+ }))
1636
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1637
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1638
+ }
1639
+ notes.push(
1640
+ state.accepted.delivered_at === undefined
1641
+ ? 'a device answer is safely journaled; the next Stop will deliver it'
1642
+ : 'a device answer has already been delivered; the next Stop will close it out',
1643
+ )
1644
+ return { notes }
1645
+ }
1286
1646
  const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
1287
1647
  let lateAnswers: AnsweredPending[] = []
1288
1648
  if (live.length > 0) {
1289
- const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS)
1290
- lateAnswers = answered
1291
- if (answered.length > 0) {
1292
- await finishAnsweredPendings(ctx, envelope, sessionId, answered, false)
1293
- for (const { reply } of answered) {
1294
- notes.push(`late answer from ${reply.device_name}: ${reply.text}`)
1649
+ const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(
1650
+ ctx,
1651
+ live,
1652
+ LATE_PROMPT_POLL_SECONDS,
1653
+ )
1654
+ const finalized = await finalizePendings(
1655
+ ctx,
1656
+ answered.map((entry) => entry.pending),
1657
+ )
1658
+ lateAnswers = answered.map((entry) => {
1659
+ const response = finalized.find((candidate) =>
1660
+ isSamePending(candidate.pending, entry.pending),
1661
+ )?.response
1662
+ const replies = response?.replies.length ? response.replies : entry.replies
1663
+ if (response === null) {
1664
+ notes.push(
1665
+ `could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
1666
+ )
1295
1667
  }
1668
+ return { ...entry, replies, reply: replies.at(-1)! }
1669
+ })
1670
+ if (answered.length > 0) {
1671
+ stageAcceptedAnswers(
1672
+ ctx,
1673
+ sessionId,
1674
+ lateAnswers,
1675
+ 0,
1676
+ )
1677
+ for (const answer of lateAnswers) reportAnswer(ctx, notes, answer, true)
1296
1678
  }
1297
- if (troubled) {
1679
+ if (transientTrouble) {
1298
1680
  notes.push('could not check every pending question for a late answer before the prompt')
1299
1681
  }
1682
+ const permanentFailure = permanentReplyFailureNote(permanentFailures)
1683
+ if (permanentFailure !== null) notes.push(permanentFailure)
1300
1684
  }
1301
1685
  // Park before dropping `pending`. If the process dies between these writes,
1302
1686
  // the next hook sees both copies and dedupes them; the old order could die in
1303
1687
  // the gap after erasing the only request/collapse/device identifiers.
1304
1688
  updateSessionState(sessionId, ctx.env, (current) => {
1305
1689
  const retiring = [...(current.retiring ?? [])]
1306
- for (const entry of pendingList(current)) {
1690
+ const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
1691
+ for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
1307
1692
  const retirement = retiringQuestion(entry, 'answered_elsewhere')
1308
1693
  if (
1309
1694
  retirement !== null &&
@@ -1312,7 +1697,21 @@ export async function handleUserPromptSubmit(
1312
1697
  retiring.push(retirement)
1313
1698
  }
1314
1699
  }
1315
- return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) }
1700
+ return {
1701
+ last_prompt_at: ctx.now(),
1702
+ ...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
1703
+ ...(unasked.length > 0 ? { pending: unasked } : {}),
1704
+ ...(retiring.length > 0 ? { retiring } : {}),
1705
+ ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1706
+ // The user typed, so whatever chain of answer-driven continuations was
1707
+ // running, a human has taken the turn and the consecutive count starts
1708
+ // over. Only a real prompt reaches here: the journal branch above returns
1709
+ // first, and a wake route's injected message always arrives while its own
1710
+ // answer is still journaled, so it can never pass for the user's presence.
1711
+ ...(current.continuation === undefined
1712
+ ? {}
1713
+ : { continuation: { ...current.continuation, count: 0 } }),
1714
+ }
1316
1715
  })
1317
1716
  // The bridge that lets a plain `notifai ask` find the hook's canonical
1318
1717
  // session: an agent shell command gets no hook payload, and not every
@@ -1321,17 +1720,19 @@ export async function handleUserPromptSubmit(
1321
1720
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1322
1721
  }
1323
1722
 
1723
+ if (lateAnswers.length > 0) {
1724
+ // UserPromptSubmit has no later hook field proving its stdout reached the
1725
+ // model. Keep the journal and let the next Stop use its acknowledged
1726
+ // continuation channel instead of recreating the crash-before-stdout gap.
1727
+ notes.push('the late device answer will continue the agent at this turn’s Stop')
1728
+ return { notes }
1729
+ }
1324
1730
  const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1325
1731
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1326
1732
  const swept = [...retired, ...orphaned]
1327
1733
  if (swept.length > 0) {
1328
1734
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1329
1735
  }
1330
- if (lateAnswers.length > 0) {
1331
- // Whatever the user did not answer was just retired above — they are at
1332
- // the keyboard now, so nothing is "still waiting" from their side.
1333
- return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes }
1334
- }
1335
1736
  return { notes }
1336
1737
  }
1337
1738
 
@@ -1339,66 +1740,312 @@ export async function handleUserPromptSubmit(
1339
1740
  // Stop — the turn ended; escalate a registered question
1340
1741
  // ---------------------------------------------------------------------------
1341
1742
 
1342
- /**
1343
- * How long an escalated question keeps accepting an answer after the turn
1344
- * ended. Long enough to survive a walk away from the desk, short enough that a
1345
- * forgotten question does not resurface days later as a live prompt.
1346
- */
1347
- const QUESTION_WINDOW_SECONDS = 3600
1348
-
1349
1743
  /**
1350
1744
  * Only engages for a question the agent explicitly registered with
1351
1745
  * `notifai ask`. Guessing from the last assistant message was the alternative
1352
1746
  * and it is not worth it: a false positive here hijacks the terminal.
1353
1747
  */
1354
- export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Promise<HookOutcome> {
1355
- const notes: string[] = []
1748
+ export async function handleStop(
1749
+ ctx: HookContext,
1750
+ envelope: HookEnvelope,
1751
+ processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000,
1752
+ route: EscalationDeliveryRoute = hookContinuationRoute(),
1753
+ ): Promise<HookOutcome> {
1356
1754
  const sessionId = envelope.session_id
1357
- if (!sessionId) return { notes }
1755
+ if (!sessionId) {
1756
+ gate(ctx, 'held', 'no-session', { stage: 'queued' })
1757
+ return { notes: [] }
1758
+ }
1759
+ return runEscalationWaiter(ctx, {
1760
+ sessionId,
1761
+ envelope,
1762
+ route,
1763
+ processDeadlineAt,
1764
+ })
1765
+ }
1358
1766
 
1359
- // Anything retired since the last hook is still live on the devices. This
1360
- // runs before every early return below, including the nagging guard: a queued
1361
- // retirement has nothing to do with whether *this* turn has a question to
1362
- // escalate, and the turn that supersedes a question is very often the one
1363
- // continuing from the previous answer.
1364
- const swept = [
1365
- ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
1366
- ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
1367
- ]
1368
- if (swept.length > 0) {
1369
- notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1767
+ /**
1768
+ * Own one registered question pipeline from its terminal-first timer through
1769
+ * its final delivery or retirement. Hosts differ only in the injected route.
1770
+ */
1771
+ export async function runEscalationWaiter(
1772
+ ctx: HookContext,
1773
+ options: EscalationWaiterOptions,
1774
+ ): Promise<HookOutcome> {
1775
+ const notes: string[] = []
1776
+ const { sessionId, envelope } = options
1777
+ const hardDeadlineAt =
1778
+ options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000
1779
+
1780
+ // This event marker is diagnostic evidence in its own right. Record it
1781
+ // before every early return so doctor can distinguish a working Stop route
1782
+ // from the prompt-only state that previously looked healthy.
1783
+ updateSessionState(sessionId, ctx.env, (current) => ({
1784
+ ...current,
1785
+ last_stop_at: ctx.now(),
1786
+ }))
1787
+
1788
+ // The waiter, not its host, owns this lease. It spans grace, submission,
1789
+ // polling, close fencing, route delivery, and every retirement path.
1790
+ // Real clock, deliberately, not `ctx.now` — the claim answers whether
1791
+ // another process is alive right now, which an injected clock cannot know.
1792
+ if (!claimQuestionPush(sessionId, ctx.env)) {
1793
+ gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
1794
+ notes.push('another hook is already handling this question')
1795
+ return { notes }
1370
1796
  }
1797
+ let settledAnswerThisPass = false
1798
+ try {
1799
+ let state = readSessionState(sessionId, ctx.env)
1800
+ if (state.accepted !== undefined) {
1801
+ const accepted = state.accepted
1802
+ // Acknowledgement is per route, because routes end in different places.
1803
+ //
1804
+ // A blocking continuation ends at this process's stdout, which the
1805
+ // harness reads only after the process exits: the recursive Stop's
1806
+ // `stop_hook_active` is the acknowledgement, and persisting a separate
1807
+ // "offered" bit before stdout would recreate the crash gap this journal
1808
+ // exists to close. An out-of-band route ends at a write this process made
1809
+ // itself and recorded here. A socket write is not proof the model acted
1810
+ // on the answer — delivery is not consumption — but nothing later can
1811
+ // prove more, and an answer redelivered without end is strictly worse
1812
+ // than one settled on delivery. So a recorded delivery settles.
1813
+ const deliveryProven =
1814
+ accepted.delivered_at !== undefined || envelope.stop_hook_active === true
1815
+ if (!deliveryProven) {
1816
+ for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
1817
+ return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes)
1818
+ }
1819
+ settleAcceptedAnswers(ctx, sessionId, accepted)
1820
+ settledAnswerThisPass = true
1821
+ state = readSessionState(sessionId, ctx.env)
1822
+ }
1371
1823
 
1372
- const state = readSessionState(sessionId, ctx.env)
1373
- const pending = pendingList(state)
1374
- if (pending.length === 0) return { notes }
1824
+ const pending = pendingList(state)
1825
+ if (pending.length === 0) {
1826
+ const swept = [
1827
+ ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
1828
+ ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
1829
+ ]
1830
+ if (swept.length > 0) {
1831
+ notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1832
+ }
1833
+ gate(ctx, 'held', 'no-question', { stage: 'queued' })
1834
+ return { notes }
1835
+ }
1836
+
1837
+ return await handleClaimedStop(
1838
+ ctx,
1839
+ envelope,
1840
+ sessionId,
1841
+ state,
1842
+ pending,
1843
+ notes,
1844
+ hardDeadlineAt,
1845
+ options.route,
1846
+ // Every route's successor turn-end, named the same way: the harness flag
1847
+ // for a blocking continuation, and the settle above for a route that woke
1848
+ // a brand-new turn out of band.
1849
+ envelope.stop_hook_active === true || settledAnswerThisPass,
1850
+ )
1851
+ } finally {
1852
+ releaseQuestionPush(sessionId, ctx.env)
1853
+ }
1854
+ }
1855
+
1856
+ /**
1857
+ * The blocking Stop hook's own last meter: the answer becomes this process's
1858
+ * stdout, and the harness admits it as a new user turn. Exported because a
1859
+ * host adapter that has other routes still falls back to this one.
1860
+ */
1861
+ export function hookContinuationRoute(): EscalationDeliveryRoute {
1862
+ return {
1863
+ kind: 'hook-continuation',
1864
+ deliver: async (event) => ({
1865
+ stdout: stopAnswerOutput(event.context),
1866
+ notes: [],
1867
+ // The harness reads this stdout after the process exits, so only the
1868
+ // successor Stop can acknowledge it.
1869
+ acknowledgement: 'stdout',
1870
+ }),
1871
+ }
1872
+ }
1873
+
1874
+ /**
1875
+ * Hand one accepted answer to a route and record what the attempt proved.
1876
+ *
1877
+ * Every delivery on every route passes through here, which is what makes the
1878
+ * cap below a backstop rather than a suggestion. A guard only one route
1879
+ * consults is not a backstop: that is how a single answer once reached a
1880
+ * session 250 times.
1881
+ */
1882
+ async function deliverAcceptedAnswers(
1883
+ ctx: HookContext,
1884
+ sessionId: string,
1885
+ route: EscalationDeliveryRoute,
1886
+ accepted: AcceptedAnswerDelivery,
1887
+ notes: string[],
1888
+ ): Promise<HookOutcome> {
1889
+ const { answers: answered, remaining } = accepted
1890
+ const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
1891
+ const attempt = (accepted.delivery_attempts ?? 0) + 1
1892
+ if (attempt > MAX_CONTINUATION_COUNT) {
1893
+ gate(ctx, 'held', 'delivery-limit', {
1894
+ route: route.kind,
1895
+ attempts: accepted.delivery_attempts ?? 0,
1896
+ limit: MAX_CONTINUATION_COUNT,
1897
+ request_ids: requestIds,
1898
+ })
1899
+ // Settle rather than journal for ever. The answer has been offered as often
1900
+ // as any working route could need, it is already in the notes of each of
1901
+ // those turns, and the questions it answered still deserve retirement.
1902
+ settleAcceptedAnswers(ctx, sessionId, accepted)
1903
+ notes.push(
1904
+ `the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
1905
+ )
1906
+ return { notes }
1907
+ }
1908
+ // Count the attempt before making it: an attempt that dies mid-delivery still
1909
+ // happened, and a counter written afterwards could be evaded by crashing.
1910
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1911
+ ...current,
1912
+ delivery_attempts: attempt,
1913
+ }))
1914
+ ctx.log?.info('hook.gate', {
1915
+ verdict: 'proceeding',
1916
+ reason: 'answered',
1917
+ stage: 'routed',
1918
+ route: route.kind,
1919
+ answers: answered.length,
1920
+ request_ids: requestIds,
1921
+ journal_recorded_at: accepted.recorded_at,
1922
+ delivery_attempt: attempt,
1923
+ })
1924
+ const delivered = await route.deliver({
1925
+ context: answersContext(answered, remaining),
1926
+ answers: answered.length,
1927
+ remaining,
1928
+ request_ids: requestIds,
1929
+ journal_recorded_at: accepted.recorded_at,
1930
+ })
1931
+ const deliveredRoute =
1932
+ typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
1933
+ const deliveredStage =
1934
+ typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
1935
+ if (delivered.acknowledgement === 'delivered') {
1936
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1937
+ ...current,
1938
+ delivered_at: ctx.now(),
1939
+ delivered_route: deliveredRoute,
1940
+ }))
1941
+ }
1942
+ ctx.log?.info('hook.answer', {
1943
+ answered: true,
1944
+ stage: deliveredStage,
1945
+ route: deliveredRoute,
1946
+ acknowledgement: delivered.acknowledgement,
1947
+ delivery_attempt: attempt,
1948
+ request_ids: requestIds,
1949
+ answers: answered.length,
1950
+ journal_recorded_at: accepted.recorded_at,
1951
+ ...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
1952
+ })
1953
+ const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
1954
+ if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
1955
+ if (delivered.log !== undefined) outcome.log = delivered.log
1956
+ return outcome
1957
+ }
1958
+
1959
+ /** The complete per-session Stop decision, after this process owns the claim. */
1960
+ async function handleClaimedStop(
1961
+ ctx: HookContext,
1962
+ envelope: HookEnvelope,
1963
+ sessionId: string,
1964
+ state: SessionState,
1965
+ pending: PendingQuestion[],
1966
+ notes: string[],
1967
+ hardDeadlineAt: number,
1968
+ route: EscalationDeliveryRoute,
1969
+ continuingFromAnswer: boolean,
1970
+ ): Promise<HookOutcome> {
1375
1971
  const live = pending.filter((entry) => entry.request_id !== undefined)
1376
1972
  const unasked = pending.filter((entry) => entry.request_id === undefined)
1973
+ let liveToEscalate = live
1974
+
1975
+ if (
1976
+ ctx.harness !== undefined &&
1977
+ HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported'
1978
+ ) {
1979
+ for (const entry of live) await closeQuietly(ctx, entry.request_id!)
1980
+ await retirePendings(ctx, envelope, sessionId, pending, 'expired')
1981
+ gate(ctx, 'held', 'no-question')
1982
+ notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
1983
+ return { notes }
1984
+ }
1377
1985
 
1378
1986
  if (live.length > 0) {
1379
- const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS)
1987
+ const { answered, permanentFailures } = await pollPendingReplies(
1988
+ ctx,
1989
+ live,
1990
+ LATE_STOP_POLL_SECONDS,
1991
+ )
1992
+ const permanentFailure = permanentReplyFailureNote(permanentFailures)
1993
+ if (permanentFailure !== null) notes.push(permanentFailure)
1994
+ const permanentlyRejected = live.filter((entry) =>
1995
+ permanentFailures.has(entry.request_id!),
1996
+ )
1997
+ await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id!)))
1998
+ if (permanentlyRejected.length > 0) {
1999
+ await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired')
2000
+ }
1380
2001
  if (answered.length > 0) {
1381
- await finishAnsweredPendings(ctx, envelope, sessionId, answered, true)
1382
- for (const { reply } of answered) {
1383
- notes.push(`late answer from ${reply.device_name}: ${reply.text}`)
1384
- }
2002
+ const finalized = await finalizePendings(
2003
+ ctx,
2004
+ answered.map((entry) => entry.pending),
2005
+ )
2006
+ const authoritative = answered.map((entry) => {
2007
+ const response = finalized.find((candidate) =>
2008
+ isSamePending(candidate.pending, entry.pending),
2009
+ )?.response
2010
+ const replies = response?.replies.length ? response.replies : entry.replies
2011
+ if (response === null) {
2012
+ notes.push(
2013
+ `could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
2014
+ )
2015
+ }
2016
+ return { ...entry, replies, reply: replies.at(-1)! }
2017
+ })
2018
+ const accepted = stageAcceptedAnswers(
2019
+ ctx,
2020
+ sessionId,
2021
+ authoritative,
2022
+ pending.length - authoritative.length - permanentlyRejected.length,
2023
+ )
2024
+ for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
1385
2025
  // Anything still unasked rides the next Stop: the agent is being resumed
1386
2026
  // with answers right now, and may not even need the rest afterwards.
1387
- return {
1388
- stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
1389
- notes,
1390
- }
2027
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
1391
2028
  }
2029
+ const recoverableLive = live.filter(
2030
+ (entry) => !permanentFailures.has(entry.request_id!),
2031
+ )
2032
+ liveToEscalate = recoverableLive
1392
2033
  if (unasked.length === 0) {
1393
- // Already live on the user's devices from an earlier Stop; asking twice
1394
- // for one question is the nagging failure this feature exists to avoid.
1395
- const ids = live.map((entry) => entry.request_id).join(', ')
1396
- notes.push(
1397
- troubled
1398
- ? `already asked (${ids}); could not check whether ${live.length === 1 ? 'its answer' : 'their answers'} arrived`
1399
- : `already asked (${ids}); waiting for ${live.length === 1 ? 'that answer' : 'those answers'}`,
2034
+ if (recoverableLive.length === 0) return { notes }
2035
+ // A previous answer may have resumed the model while independent
2036
+ // questions remain. The next Stop is their successor owner: keep waiting
2037
+ // to the original absolute deadline instead of polling for four seconds
2038
+ // and abandoning them.
2039
+ return await escalate(
2040
+ ctx,
2041
+ envelope,
2042
+ sessionId,
2043
+ [],
2044
+ recoverableLive,
2045
+ notes,
2046
+ hardDeadlineAt,
2047
+ route,
1400
2048
  )
1401
- return { notes }
1402
2049
  }
1403
2050
  // Questions registered after the earlier push still owe the user their
1404
2051
  // notification; fall through and escalate just those.
@@ -1415,36 +2062,54 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1415
2062
  (entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
1416
2063
  )
1417
2064
  if (!isNew) {
2065
+ gate(ctx, 'held', 'continuation-repeat')
1418
2066
  notes.push('already continuing from an answer; not asking again this turn')
1419
2067
  return { notes }
1420
2068
  }
1421
- if (continuation.count >= MAX_CONTINUATION_COUNT) {
1422
- notes.push(
1423
- `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
1424
- )
1425
- return { notes }
1426
- }
1427
2069
  }
1428
- if (!ctx.config.ask_notifications.value) return { notes }
1429
- if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
1430
- notes.push('you are at the keyboard; leaving the question in the terminal')
2070
+ // The cap is deliberately not keyed to `stop_hook_active`. That flag names one
2071
+ // route's continuation; a route that wakes a brand-new turn never sets it, and
2072
+ // a limit only one route consults bounds only that route. `continuingFromAnswer`
2073
+ // is the same event named for every route, so the chain of answer → follow-up
2074
+ // question → answer is bounded whatever delivered it.
2075
+ if (
2076
+ continuingFromAnswer &&
2077
+ state.continuation !== undefined &&
2078
+ state.continuation.count >= MAX_CONTINUATION_COUNT
2079
+ ) {
2080
+ gate(ctx, 'held', 'continuation-limit', {
2081
+ count: state.continuation.count,
2082
+ limit: MAX_CONTINUATION_COUNT,
2083
+ route: route.kind,
2084
+ })
2085
+ notes.push(
2086
+ `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
2087
+ )
1431
2088
  return { notes }
1432
2089
  }
1433
-
1434
- // Claim before the grace window, not after: two racing hooks would otherwise
1435
- // both wait, both find the user still absent, and both push.
1436
- // Real clock, deliberately, not `ctx.now` — the claim answers "is another
1437
- // process alive right now", which the injectable clock cannot speak to. It
1438
- // is also the only clock the *other* process shares.
1439
- if (!claimQuestionPush(sessionId, ctx.env)) {
1440
- notes.push('another hook is already handling this question')
2090
+ // Silent to the user by design — they switched routing off, so saying so on
2091
+ // every turn would be nagging about their own setting. That silence is also
2092
+ // why it belongs in the log: from outside, "ask_notifications = false" and "a
2093
+ // bug ate my question" look exactly the same.
2094
+ if (!ctx.config.ask_notifications.value) {
2095
+ gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
1441
2096
  return { notes }
1442
2097
  }
1443
- try {
1444
- return await escalate(ctx, envelope, sessionId, unasked, live, notes)
1445
- } finally {
1446
- releaseQuestionPush(sessionId, ctx.env)
1447
- }
2098
+ gate(ctx, 'proceeding', 'proceeding', {
2099
+ unasked: unasked.length,
2100
+ already_live: liveToEscalate.length,
2101
+ grace_seconds: ctx.config.ask_grace_seconds.value,
2102
+ })
2103
+ return await escalate(
2104
+ ctx,
2105
+ envelope,
2106
+ sessionId,
2107
+ unasked,
2108
+ liveToEscalate,
2109
+ notes,
2110
+ hardDeadlineAt,
2111
+ route,
2112
+ )
1448
2113
  }
1449
2114
 
1450
2115
  /** The escalation itself, split out so the claim is released on every path. */
@@ -1455,137 +2120,329 @@ async function escalate(
1455
2120
  unasked: PendingQuestion[],
1456
2121
  alreadyLive: PendingQuestion[],
1457
2122
  notes: string[],
2123
+ hardDeadlineAt: number,
2124
+ route: EscalationDeliveryRoute,
1458
2125
  ): Promise<HookOutcome> {
1459
- // Away right now, but the questions still owe the user their terminal-first
1460
- // window before anything reaches their devices — measured from the oldest
1461
- // registration, because that is the question that has waited longest.
1462
- const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
1463
- const grace = await awaitGrace(ctx, oldest)
1464
- if (grace === 'user-returned') {
1465
- notes.push('you came back before the wait elapsed; leaving the questions in the terminal')
1466
- return { notes }
1467
- }
1468
- if (grace === 'no-signal') {
1469
- notes.push('no idle signal on this machine; asking now rather than holding the terminal')
2126
+ // The questions still owe the user their terminal-first window before
2127
+ // anything reaches their devices — measured from the oldest registration,
2128
+ // because that is the question that has waited longest.
2129
+ const startedAt = ctx.now()
2130
+ const existingDeadlines = alreadyLive.flatMap((entry) =>
2131
+ entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
2132
+ ? []
2133
+ : [entry.reply_deadline_at],
2134
+ )
2135
+ const ceilingAt = Math.min(
2136
+ hardDeadlineAt,
2137
+ ...(existingDeadlines.length === 0 ? [] : existingDeadlines),
2138
+ )
2139
+ if (unasked.length > 0 && alreadyLive.length === 0) {
2140
+ const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
2141
+ await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
2142
+ ctx.log?.debug('hook.gate', {
2143
+ verdict: 'grace',
2144
+ reason: 'elapsed',
2145
+ waited_from: oldest,
2146
+ grace_seconds: ctx.config.ask_grace_seconds.value,
2147
+ })
1470
2148
  }
1471
-
1472
2149
  // Phase one: every registered question reaches the user's devices, each as
1473
2150
  // its own notification — one ask never stands in for another.
1474
2151
  const submitted: PendingQuestion[] = []
1475
2152
  for (const entry of unasked) {
2153
+ // The reply window is the question's own: whatever is left of the waiter's
2154
+ // ceiling, granted to the server so it stops accepting answers at the same
2155
+ // moment this owner stops listening for them.
2156
+ const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000)
2157
+ if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
2158
+ notes.push(
2159
+ 'too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal',
2160
+ )
2161
+ continue
2162
+ }
2163
+ const ownerDeadlineAt = ceilingAt
1476
2164
  const questions = pendingQuestions(entry)
1477
- const sent = await submitQuestion(ctx, {
1478
- title:
1479
- ctx.config.project.value === null
1480
- ? 'Question'
1481
- : `Question · ${ctx.config.project.value}`,
1482
- // A set is answered on the expanded card; the banner leads with the
1483
- // first question and says how much more is waiting behind it.
1484
- body:
1485
- questions.length > 1
1486
- ? `${questions[0]!.text} (+${questions.length - 1} more)`
1487
- : entry.question,
1488
- questions,
1489
- ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
1490
- event: 'agent_question',
1491
- session: sessionLabel(ctx, envelope),
1492
- // Outlives the block, and stays open on purpose: the answer is still
1493
- // useful to the next turn, which collects it with `notifai replies`.
1494
- windowSeconds: QUESTION_WINDOW_SECONDS,
1495
- })
1496
- if ('error' in sent) {
1497
- notes.push(sent.error)
2165
+ let intent = entry.submission
2166
+ if (intent === undefined) {
2167
+ const prepared = await prepareQuestionSubmission(ctx, {
2168
+ title:
2169
+ ctx.config.project.value === null
2170
+ ? 'Question'
2171
+ : `Question · ${ctx.config.project.value}`,
2172
+ // A set is answered on the expanded card; the banner leads with the
2173
+ // first question and says how much more is waiting behind it.
2174
+ body:
2175
+ questions.length > 1
2176
+ ? `${questions[0]!.text} (+${questions.length - 1} more)`
2177
+ : entry.question,
2178
+ questions,
2179
+ ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
2180
+ event: 'agent_question',
2181
+ session: sessionLabel(ctx, envelope),
2182
+ windowSeconds: replyWindowSeconds,
2183
+ ownerDeadlineAt,
2184
+ })
2185
+ if ('error' in prepared) {
2186
+ ctx.log?.error('hook.pushed', { ok: false, message: prepared.error })
2187
+ notes.push(prepared.error)
2188
+ continue
2189
+ }
2190
+ intent = prepared
2191
+ // Durable before submit. If the server commits and the response is lost,
2192
+ // the reserved request id still lets this owner poll and finalize the
2193
+ // exact card; a successor can also replay the frozen draft/key.
2194
+ updateSessionState(sessionId, ctx.env, (current) => {
2195
+ const list = pendingList(current)
2196
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
2197
+ if (index < 0) return current
2198
+ const next = [...list]
2199
+ next[index] = { ...next[index]!, submission: prepared }
2200
+ return { ...current, pending: next }
2201
+ })
2202
+ } else if (intent.owner_deadline_at <= ctx.now()) {
2203
+ // The frozen wire identity survives a crashed owner, but its local owner
2204
+ // lease does not. Re-arm only the local deadline; request id, key,
2205
+ // targets, and draft remain byte-identical for idempotent replay.
2206
+ intent = { ...intent, owner_deadline_at: ceilingAt }
2207
+ const rearmed = intent
2208
+ updateSessionState(sessionId, ctx.env, (current) => {
2209
+ const list = pendingList(current)
2210
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
2211
+ if (index < 0) return current
2212
+ const next = [...list]
2213
+ next[index] = { ...next[index]!, submission: rearmed }
2214
+ return { ...current, pending: next }
2215
+ })
2216
+ }
2217
+ if (ctx.now() >= intent.owner_deadline_at) {
2218
+ notes.push('the waiter ceiling ended before submission; preserving the frozen intent')
2219
+ continue
2220
+ }
2221
+ const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
2222
+ if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
2223
+ notes.push(
2224
+ 'device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts',
2225
+ )
1498
2226
  continue
1499
2227
  }
2228
+ let receipt: SubmissionReceipt | undefined
2229
+ let admissionConfirmed = false
2230
+ try {
2231
+ receipt = await submitQuestion(ctx, intent)
2232
+ admissionConfirmed = true
2233
+ } catch (err) {
2234
+ if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
2235
+ // A concrete client response is an admission rejection, not an
2236
+ // ambiguous commit. Keep the frozen intent for a later retry and leave
2237
+ // the question in the terminal; polling a request the server says it
2238
+ // did not accept only burns the owner window and hides the real fault.
2239
+ notes.push(
2240
+ `question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
2241
+ )
2242
+ ctx.log?.error('hook.pushed', {
2243
+ ok: false,
2244
+ request_id: intent.request_id,
2245
+ status: err.status,
2246
+ code: err.code,
2247
+ message: err.message,
2248
+ })
2249
+ continue
2250
+ }
2251
+ // This is deliberately not a fail-open return. A transport failure is
2252
+ // ambiguous: the server may already have dispatched the card. The
2253
+ // client-generated request id keeps polling/finalization possible.
2254
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
2255
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
2256
+ }
2257
+ if (admissionConfirmed) {
2258
+ ctx.log?.info('hook.pushed', {
2259
+ ok: true,
2260
+ request_id: intent.request_id,
2261
+ devices: intent.device_ids.length,
2262
+ questions: questions.length,
2263
+ text: questions[0]!.text,
2264
+ })
2265
+ }
2266
+ const committedReplyDeadline =
2267
+ receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
2268
+ ? intent.owner_deadline_at
2269
+ : Date.parse(receipt.reply_expires_at)
1500
2270
  const live: PendingQuestion = {
1501
2271
  ...entry,
1502
- request_id: sent.requestId,
1503
- collapse_key: sent.collapseKey,
1504
- device_ids: sent.devices,
2272
+ request_id: intent.request_id,
2273
+ collapse_key: intent.collapse_key,
2274
+ device_ids: intent.device_ids,
2275
+ // Submission and every later poll share one wall-clock owner. The
2276
+ // server may start its relative window a little later while the submit
2277
+ // response is in flight; closing at this deadline is therefore the
2278
+ // authoritative backstop against accepting an answer into a void.
2279
+ reply_deadline_at: Math.min(
2280
+ intent.owner_deadline_at,
2281
+ Number.isFinite(committedReplyDeadline)
2282
+ ? committedReplyDeadline
2283
+ : intent.owner_deadline_at,
2284
+ ),
1505
2285
  }
2286
+ delete live.submission
1506
2287
  submitted.push(live)
1507
2288
  // Record what is now live on the user's devices BEFORE any wait. If we
1508
2289
  // only learned these ids afterwards, a question that timed out would
1509
2290
  // leave no trace, and the user returning to the terminal could never
1510
2291
  // retire it — the notification would stay answerable for an hour with
1511
2292
  // nobody listening.
1512
- updateSessionState(sessionId, ctx.env, (current) => {
1513
- const list = pendingList(current)
1514
- const index = list.findIndex(
1515
- (candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
1516
- )
1517
- if (index >= 0) {
1518
- const next = [...list]
1519
- next[index] = live
1520
- return { ...current, pending: next }
1521
- }
1522
- // The entry vanished while the submit was in flight (the user's prompt
1523
- // wiped the queue). The delivered notification must still be retirable,
1524
- // so park it rather than lose its only identifiers.
1525
- const retirement = retiringQuestion(live, 'answered_elsewhere')!
1526
- const retiring = [...(current.retiring ?? [])]
1527
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1528
- retiring.push(retirement)
1529
- }
1530
- return { ...current, retiring }
1531
- })
2293
+ if (admissionConfirmed) {
2294
+ updateSessionState(sessionId, ctx.env, (current) => {
2295
+ const list = pendingList(current)
2296
+ const index = list.findIndex(
2297
+ (candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
2298
+ )
2299
+ if (index >= 0) {
2300
+ const next = [...list]
2301
+ next[index] = live
2302
+ return { ...current, pending: next }
2303
+ }
2304
+ // The entry vanished while the submit was in flight (the user's prompt
2305
+ // wiped the queue). The delivered notification must still be retirable,
2306
+ // so park it rather than lose its only identifiers.
2307
+ const retirement = retiringQuestion(live, 'answered_elsewhere')!
2308
+ const retiring = [...(current.retiring ?? [])]
2309
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
2310
+ retiring.push(retirement)
2311
+ }
2312
+ return { ...current, retiring }
2313
+ })
2314
+ }
1532
2315
  }
1533
2316
 
1534
- const waitingOn = [...alreadyLive, ...submitted]
1535
- if (waitingOn.length === 0) return { notes }
1536
-
1537
- if (ctx.harness === 'opencode') {
2317
+ const staleLive = alreadyLive.filter(
2318
+ (entry) =>
2319
+ entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now(),
2320
+ )
2321
+ const finalizedStale = await finalizePendings(ctx, staleLive)
2322
+ const staleAnswers = finalizedStale
2323
+ .map(finalizedAnswer)
2324
+ .filter((entry): entry is AnsweredPending => entry !== null)
2325
+ const staleSilence = finalizedStale
2326
+ .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
2327
+ .map((entry) => entry.pending)
2328
+ const staleUnproven = finalizedStale.filter((entry) => entry.response === null)
2329
+ if (staleSilence.length > 0) {
2330
+ await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired')
1538
2331
  notes.push(
1539
- `question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`,
2332
+ `expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`,
1540
2333
  )
1541
- return { notes }
1542
2334
  }
2335
+ if (staleUnproven.length > 0) {
2336
+ notes.push(
2337
+ `could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`,
2338
+ )
2339
+ }
2340
+ if (staleAnswers.length > 0) {
2341
+ const remaining = Math.max(
2342
+ 0,
2343
+ pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
2344
+ )
2345
+ const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
2346
+ for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
2347
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2348
+ }
2349
+ const waitingOn = [
2350
+ ...alreadyLive.filter(
2351
+ (entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now(),
2352
+ ),
2353
+ ...submitted,
2354
+ ]
2355
+ if (waitingOn.length === 0) return { notes }
1543
2356
 
1544
- // Phase two: one blocking wait across everything live, old and new alike.
1545
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
1546
- const waited = await waitForAnyReplyWhileAway(
2357
+ // Phase two: one wait across everything live, old and new alike.
2358
+ const ownerDeadline = Math.min(
2359
+ ceilingAt,
2360
+ Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
2361
+ )
2362
+ const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
2363
+ const waited = await waitForAnyReply(
1547
2364
  ctx,
1548
2365
  waitingOn.map((entry) => entry.request_id!),
1549
2366
  timeoutSeconds,
1550
2367
  )
2368
+ const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
2369
+ if (permanentFailure !== null) notes.push(permanentFailure)
1551
2370
 
1552
2371
  if (waited.byRequest.size === 0) {
1553
- // Keep the pending records so a returning user's UserPromptSubmit can
1554
- // retire the notifications still live on their devices. `request_id`
1555
- // being set is also what stops the next Stop pushing them again.
1556
- const ids = waitingOn.map((entry) => entry.request_id).join(', ')
1557
- if (waited.userReturned) {
2372
+ // Once this owner returns, no shipped command-hook harness can guarantee
2373
+ // a later answer reaches the same agent turn. Close first, then retire the
2374
+ // local records, so the Companion never accepts an answer into a void.
2375
+ const finalized = await finalizePendings(ctx, waitingOn)
2376
+ const finalAnswers = finalized
2377
+ .map(finalizedAnswer)
2378
+ .filter((entry): entry is AnsweredPending => entry !== null)
2379
+ const confirmedSilent = finalized
2380
+ .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
2381
+ .map((entry) => entry.pending)
2382
+ const unproven = finalized.filter((entry) => entry.response === null)
2383
+ if (confirmedSilent.length > 0) {
2384
+ await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired')
2385
+ }
2386
+ if (unproven.length > 0) {
1558
2387
  notes.push(
1559
- `you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; returning the terminal while ${waitingOn.length === 1 ? 'it stays' : 'they stay'} answerable (${ids}). ` +
1560
- 'Retrieve answers with: notifai replies --pending',
2388
+ `could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`,
2389
+ )
2390
+ }
2391
+ if (finalAnswers.length > 0) {
2392
+ const remaining = Math.max(
2393
+ 0,
2394
+ pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
1561
2395
  )
1562
- } else {
2396
+ const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
2397
+ for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
2398
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2399
+ }
2400
+ const requestIdSummary = summarizeRequestIds(waitingOn)
2401
+ ctx.log?.info('hook.answer', {
2402
+ answered: false,
2403
+ stage: 'queued',
2404
+ request_ids: requestIdSummary.ids,
2405
+ degraded: waited.degraded,
2406
+ permanent_failures: waited.permanentFailures.size,
2407
+ waited_seconds: timeoutSeconds,
2408
+ })
2409
+ if (waited.permanentFailures.size === 0) {
1563
2410
  notes.push(
1564
2411
  waited.degraded
1565
- ? 'could not reach the server to find out whether you answered; check with: notifai replies --pending'
1566
- : 'no answer in time; retrieve it later with: notifai replies --pending',
2412
+ ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
2413
+ : 'no answer in time; the question expired with its continuation owner',
1567
2414
  )
1568
2415
  }
1569
2416
  return { notes }
1570
2417
  }
1571
2418
 
2419
+ const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id!))
2420
+ const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
1572
2421
  const answered: AnsweredPending[] = []
1573
- for (const entry of waitingOn) {
2422
+ for (const finalized of finalizedAnswered) {
2423
+ const entry = finalized.pending
1574
2424
  const replies = waited.byRequest.get(entry.request_id!)
1575
2425
  if (replies === undefined) continue
1576
- // First answer claims the question across devices; the close is what
1577
- // enforces that, and the latest reply within the window is the answer.
1578
- await closeQuietly(ctx, entry.request_id!)
1579
- answered.push({ pending: entry, reply: replies.at(-1)!, replies })
1580
- }
1581
- await finishAnsweredPendings(ctx, envelope, sessionId, answered, true)
1582
- for (const { reply } of answered) {
1583
- notes.push(`answer from ${reply.device_name}: ${reply.text}`)
1584
- }
1585
- return {
1586
- stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1587
- notes,
2426
+ // The fenced set wins because it includes corrections that committed
2427
+ // while close was waiting for earlier writers. If finalization itself was
2428
+ // unreachable, never discard the durable reply already observed.
2429
+ const finalReplies = finalized.response?.replies ?? []
2430
+ const authoritative = finalReplies.length > 0 ? finalReplies : replies
2431
+ answered.push({ pending: entry, reply: authoritative.at(-1)!, replies: authoritative })
2432
+ if (finalized.response === null) {
2433
+ notes.push(
2434
+ `could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
2435
+ )
2436
+ }
1588
2437
  }
2438
+ const accepted = stageAcceptedAnswers(
2439
+ ctx,
2440
+ sessionId,
2441
+ answered,
2442
+ waitingOn.length - answered.length,
2443
+ )
2444
+ for (const answer of answered) reportAnswer(ctx, notes, answer, false)
2445
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
1589
2446
  }
1590
2447
 
1591
2448
  // ---------------------------------------------------------------------------
@@ -1607,7 +2464,7 @@ export function handleSessionEnd(
1607
2464
  ): HookOutcome {
1608
2465
  const notes: string[] = []
1609
2466
  const sessionId = envelope.session_id
1610
- if (!sessionId) return { notes }
2467
+ if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
1611
2468
  if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
1612
2469
 
1613
2470
  const state = readSessionState(sessionId, env)
@@ -1620,7 +2477,10 @@ export function handleSessionEnd(
1620
2477
  notes.push(err instanceof Error ? err.message : String(err))
1621
2478
  // Preserve the only identifiers instead of turning an explicit corrupt
1622
2479
  // state into an unretirable question during SessionEnd cleanup.
1623
- return { notes }
2480
+ return {
2481
+ notes,
2482
+ log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
2483
+ }
1624
2484
  }
1625
2485
  }
1626
2486
  if (orphans.length > 0) {
@@ -1630,8 +2490,24 @@ export function handleSessionEnd(
1630
2490
  `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
1631
2491
  )
1632
2492
  }
2493
+ if (state.accepted !== undefined) {
2494
+ const preserved: SessionState = { accepted: state.accepted }
2495
+ if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
2496
+ if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
2497
+ if (state.continuation !== undefined) preserved.continuation = state.continuation
2498
+ writeSessionState(sessionId, env, preserved)
2499
+ notes.push('preserved an accepted device answer for this exact session to resume')
2500
+ return {
2501
+ notes,
2502
+ log: {
2503
+ outcome: 'answer-preserved',
2504
+ queued_retirements: orphans.length,
2505
+ accepted_answers: state.accepted.answers.length,
2506
+ },
2507
+ }
2508
+ }
1633
2509
  clearSessionState(sessionId, env)
1634
- return { notes }
2510
+ return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
1635
2511
  }
1636
2512
 
1637
2513
  // ---------------------------------------------------------------------------
@@ -1676,6 +2552,7 @@ export function registerQuestion(
1676
2552
  pending: [
1677
2553
  ...pending,
1678
2554
  {
2555
+ question_id: `q_${randomBytes(12).toString('base64url')}`,
1679
2556
  asked_at: now,
1680
2557
  ...question,
1681
2558
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),