@raidiant/notifai 0.3.0 → 0.4.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 (80) hide show
  1. package/README.md +20 -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/client.test.ts +12 -0
  52. package/src/client.ts +72 -4
  53. package/src/commands.test.ts +891 -43
  54. package/src/commands.ts +1133 -332
  55. package/src/config-schema.ts +51 -9
  56. package/src/config.test.ts +6 -5
  57. package/src/config.ts +85 -16
  58. package/src/file-lock-process-worker.test.ts +64 -0
  59. package/src/file-lock.test.ts +279 -0
  60. package/src/file-lock.ts +318 -0
  61. package/src/harnesses.test.ts +48 -0
  62. package/src/harnesses.ts +59 -0
  63. package/src/hook-adapter.test.ts +187 -0
  64. package/src/hook-adapter.ts +212 -0
  65. package/src/hook-input.test.ts +39 -0
  66. package/src/hook-input.ts +72 -0
  67. package/src/hooks.test.ts +1318 -240
  68. package/src/hooks.ts +1046 -296
  69. package/src/install-hooks.test.ts +90 -41
  70. package/src/install-hooks.ts +259 -74
  71. package/src/interactive.ts +3 -3
  72. package/src/logging-process-worker.test.ts +39 -0
  73. package/src/logging.test.ts +453 -0
  74. package/src/logging.ts +639 -0
  75. package/src/main.ts +113 -32
  76. package/src/opencode-plugin.ts +15 -16
  77. package/src/session-end-log-lock-worker.test.ts +35 -0
  78. package/src/skill.test.ts +11 -0
  79. package/src/ui/config-view.test.ts +4 -2
  80. package/src/ui/config-view.ts +3 -0
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,15 +34,17 @@ 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 { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING, type Harness } from './harnesses.js'
26
40
 
27
41
  /**
28
42
  * Harness hook handlers.
29
43
  *
30
44
  * 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.
45
+ * event and an event that fires when the user submits a prompt. Claude Code
46
+ * and Codex can continue directly from a turn-end answer. Harnesses without a
47
+ * proven exact-session continuation fail closed at question admission.
35
48
  *
36
49
  * The load-bearing constraint is that these hooks are synchronous: while one
37
50
  * blocks, the harness cannot show its own prompt either. So a hook may only
@@ -57,6 +70,8 @@ export interface HookEnvelope {
57
70
  export interface SessionState {
58
71
  /** Epoch ms of the user's last prompt in this session — our presence signal. */
59
72
  last_prompt_at?: number
73
+ /** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
74
+ last_stop_at?: number
60
75
  /**
61
76
  * Questions registered by `notifai ask`, in registration order, each
62
77
  * awaiting escalation or its answer. A list, deliberately: registering a
@@ -84,6 +99,20 @@ export interface SessionState {
84
99
  answered_at: number
85
100
  count: number
86
101
  }
102
+ /**
103
+ * A phone answer durably captured but not yet acknowledged by a successor
104
+ * hook. It stays here until the next Stop proves the harness processed the
105
+ * continuation; a crash before stdout therefore replays instead of erasing
106
+ * the user's answer.
107
+ */
108
+ accepted?: AcceptedAnswerDelivery
109
+ }
110
+
111
+ interface AcceptedAnswerDelivery {
112
+ answers: AnsweredPending[]
113
+ remaining: number
114
+ recorded_at: number
115
+ continuation_count?: number
87
116
  }
88
117
 
89
118
  /** A delivered question awaiting its retirement push. */
@@ -127,6 +156,8 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
127
156
  const ORPHAN_QUEUE_CAP = 50
128
157
 
129
158
  export interface PendingQuestion {
159
+ /** Stable local identity across racing state writers and submit recovery. */
160
+ question_id?: string
130
161
  /** One-line summary: the single question's text, or the set's first. */
131
162
  question: string
132
163
  /**
@@ -147,17 +178,42 @@ export interface PendingQuestion {
147
178
  collapse_key?: string
148
179
  /** Exact fanout of the live question; routing config may change afterwards. */
149
180
  device_ids?: string[]
181
+ /** Absolute end of the server reply window and its local continuation owner. */
182
+ reply_deadline_at?: number
183
+ /** Frozen before the first network byte, so an ambiguous submit is replayable. */
184
+ submission?: PendingSubmissionIntent
185
+ }
186
+
187
+ interface PendingSubmissionIntent {
188
+ request_id: string
189
+ idempotency_key: string
190
+ collapse_key: string
191
+ device_ids: string[]
192
+ draft: NotificationDraftT
193
+ owner_deadline_at: number
194
+ }
195
+
196
+ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
197
+ ids: string[]
198
+ display: string
199
+ } {
200
+ const ids = entries.flatMap((entry) =>
201
+ entry.request_id === undefined ? [] : [entry.request_id],
202
+ )
203
+ return { ids, display: ids.join(', ') }
150
204
  }
151
205
 
152
206
  /** How often the grace window rechecks whether the user has come back. */
153
207
  const GRACE_POLL_MS = 5_000
154
208
 
155
209
  /**
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.
210
+ * Total seconds the complete Stop invocation may own. Generated adapters grant
211
+ * another minute of host headroom, because a killed hook can lose an answer
212
+ * the user has already given. Grace and reply waiting share this budget while
213
+ * the reply retains a small actionable window and finalization reserve.
159
214
  */
160
- const STOP_BUDGET_SECONDS = 480
215
+ const STOP_BUDGET_SECONDS = HOOK_TIMING.totalSeconds
216
+ const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds
161
217
 
162
218
  export type GraceOutcome =
163
219
  /** The window elapsed with the user still gone; escalate. */
@@ -188,10 +244,10 @@ export type GraceOutcome =
188
244
  */
189
245
  export async function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome> {
190
246
  const threshold = ctx.config.away_after_seconds.value
191
- // Never let the grace window eat the reply wait's share of the hook budget.
247
+ // Keep a real device-answer window even when a user selects maximum grace.
192
248
  const graceSeconds = Math.min(
193
249
  ctx.config.ask_grace_seconds.value,
194
- Math.max(0, STOP_BUDGET_SECONDS - ctx.config.hook_reply_timeout_seconds.value),
250
+ HOOK_TIMING.maxGraceSeconds,
195
251
  )
196
252
  // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
197
253
  // future would wait far past the hook's budget, one from the distant past
@@ -217,6 +273,8 @@ export interface HookOutcome {
217
273
  stdout?: string
218
274
  /** Diagnostics; harnesses surface hook stderr in the transcript. */
219
275
  notes: string[]
276
+ /** Structured lifecycle detail that belongs on hook.end without user text. */
277
+ log?: Record<string, unknown>
220
278
  }
221
279
 
222
280
  function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
@@ -272,55 +330,6 @@ function updateSessionState(
272
330
  })
273
331
  }
274
332
 
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
333
  /**
325
334
  * Session state a crashed harness left behind.
326
335
  *
@@ -384,8 +393,9 @@ export function pruneAbandonedSessions(
384
393
  * Exclusive create is atomic on POSIX, so exactly one process gets the claim
385
394
  * and the other steps aside. A short guard serializes stale replacement with
386
395
  * 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.
396
+ * unlinking its replacement. A live PID owns the claim regardless of age; a
397
+ * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
398
+ * no trustworthy PID fall back to an age limit.
389
399
  */
390
400
  const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
391
401
  const heldClaims = new Map<string, string>()
@@ -412,9 +422,13 @@ export function claimQuestionPush(
412
422
  } catch {
413
423
  // Held. Break it only if whoever holds it cannot still be running.
414
424
  try {
415
- const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
425
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown; pid?: unknown }
416
426
  const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
417
- if (age >= 0 && age < CLAIM_TTL_MS) return false
427
+ if (typeof held.pid === 'number') {
428
+ if (processIsAlive(held.pid)) return false
429
+ } else if (age >= 0 && age < CLAIM_TTL_MS) {
430
+ return false
431
+ }
418
432
  } catch {
419
433
  // Unreadable or corrupt: treat as abandoned.
420
434
  }
@@ -436,6 +450,15 @@ export function claimQuestionPush(
436
450
  }
437
451
  }
438
452
 
453
+ function processIsAlive(pid: number): boolean {
454
+ try {
455
+ process.kill(pid, 0)
456
+ return true
457
+ } catch (err) {
458
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
459
+ }
460
+ }
461
+
439
462
  export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
440
463
  const file = claimPath(sessionId, env)
441
464
  const token = heldClaims.get(file)
@@ -490,7 +513,11 @@ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
490
513
  */
491
514
  const MAX_STORED_QUESTION_CHARS = 2000
492
515
 
493
- /** Records which session is working in a directory, for `notifai ask`. */
516
+ interface StoredProjectSessionPointer extends ProjectSessionPointer {
517
+ updatedAt: number
518
+ }
519
+
520
+ /** Records every live session working in a directory, for `notifai ask`. */
494
521
  export function writeProjectSession(
495
522
  cwd: string,
496
523
  env: NodeJS.ProcessEnv,
@@ -500,9 +527,21 @@ export function writeProjectSession(
500
527
  ): void {
501
528
  const file = projectSessionPointerPath(cwd, env)
502
529
  withFileLock(`${file}.lock`, () => {
530
+ const existing = readStoredProjectSessionPointers(file).filter(
531
+ (entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000,
532
+ )
503
533
  atomicWriteFileSync(
504
534
  file,
505
- `${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`,
535
+ `${JSON.stringify({
536
+ sessions: [
537
+ ...existing.map((entry) => ({
538
+ session_id: entry.sessionId,
539
+ updated_at: entry.updatedAt,
540
+ harness: entry.harness,
541
+ })),
542
+ { session_id: sessionId, updated_at: now, harness },
543
+ ],
544
+ })}\n`,
506
545
  )
507
546
  })
508
547
  }
@@ -512,6 +551,34 @@ export interface ProjectSessionPointer {
512
551
  harness: HookHarness
513
552
  }
514
553
 
554
+ function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
555
+ if (!existsSync(file)) return []
556
+ try {
557
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { sessions?: unknown }
558
+ if (!Array.isArray(parsed.sessions)) return []
559
+ return parsed.sessions.flatMap((candidate): StoredProjectSessionPointer[] => {
560
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return []
561
+ const entry = candidate as {
562
+ session_id?: unknown
563
+ updated_at?: unknown
564
+ harness?: unknown
565
+ }
566
+ if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
567
+ if (typeof entry.updated_at !== 'number') return []
568
+ if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
569
+ return [
570
+ {
571
+ sessionId: entry.session_id,
572
+ updatedAt: entry.updated_at,
573
+ harness: entry.harness as HookHarness,
574
+ },
575
+ ]
576
+ })
577
+ } catch {
578
+ return []
579
+ }
580
+ }
581
+
515
582
  /**
516
583
  * Resolves the session working in `cwd`. Stale pointers are ignored rather than
517
584
  * trusted: an id left by a session that ended days ago would send the question
@@ -533,31 +600,56 @@ export function readProjectSessionPointer(
533
600
  maxAgeMs = 24 * 3600 * 1000,
534
601
  ): ProjectSessionPointer | null {
535
602
  const file = projectSessionPointerPath(cwd, env)
536
- if (!existsSync(file)) return null
603
+ const live = readStoredProjectSessionPointers(file)
604
+ .filter((entry) => now - entry.updatedAt <= maxAgeMs)
605
+ .filter((entry) => {
606
+ try {
607
+ // A pointer is routing evidence only while its session state still
608
+ // exists and parses. SessionEnd and explicit cleanup therefore
609
+ // invalidate it even when a crash prevented removal from this index.
610
+ const sessionFile = sessionStatePath(entry.sessionId, env)
611
+ if (!existsSync(sessionFile)) return false
612
+ const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
613
+ return typeof state === 'object' && state !== null && !Array.isArray(state)
614
+ } catch {
615
+ return false
616
+ }
617
+ })
618
+ .sort((left, right) => right.updatedAt - left.updatedAt)
619
+ const latest = live[0]
620
+ return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
621
+ }
622
+
623
+ export function readMatchingProjectSessionPointer(
624
+ cwd: string,
625
+ env: NodeJS.ProcessEnv,
626
+ now: number,
627
+ sessionId: string,
628
+ harness: HookHarness,
629
+ maxAgeMs = 24 * 3600 * 1000,
630
+ ): ProjectSessionPointer | null {
631
+ const file = projectSessionPointerPath(cwd, env)
632
+ const match = readStoredProjectSessionPointers(file).find(
633
+ (entry) =>
634
+ entry.sessionId === sessionId &&
635
+ entry.harness === harness &&
636
+ now - entry.updatedAt <= maxAgeMs,
637
+ )
638
+ if (match === undefined) return null
537
639
  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
640
  // A pointer is routing evidence only while its session state still exists
547
641
  // and parses. SessionEnd and explicit cleanup therefore invalidate it even
548
642
  // when a crash prevented the pointer file itself from being removed.
549
- const sessionFile = sessionStatePath(parsed.session_id, env)
643
+ const sessionFile = sessionStatePath(match.sessionId, env)
550
644
  if (!existsSync(sessionFile)) return null
551
645
  const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
552
646
  if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
553
- return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
647
+ return { sessionId: match.sessionId, harness: match.harness }
554
648
  } catch {
555
649
  return null
556
650
  }
557
651
  }
558
652
 
559
- const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
560
-
561
653
  function clearMatchingProjectSession(
562
654
  cwd: string,
563
655
  env: NodeJS.ProcessEnv,
@@ -565,12 +657,23 @@ function clearMatchingProjectSession(
565
657
  ): void {
566
658
  const file = projectSessionPointerPath(cwd, env)
567
659
  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.
660
+ const remaining = readStoredProjectSessionPointers(file).filter(
661
+ (entry) => entry.sessionId !== sessionId,
662
+ )
663
+ if (remaining.length === 0) {
664
+ rmSync(file, { force: true })
665
+ return
573
666
  }
667
+ atomicWriteFileSync(
668
+ file,
669
+ `${JSON.stringify({
670
+ sessions: remaining.map((entry) => ({
671
+ session_id: entry.sessionId,
672
+ updated_at: entry.updatedAt,
673
+ harness: entry.harness,
674
+ })),
675
+ })}\n`,
676
+ )
574
677
  })
575
678
  }
576
679
 
@@ -641,19 +744,50 @@ export interface HookContext {
641
744
  requestId: string,
642
745
  timeoutSeconds: number,
643
746
  ) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
644
- /** OpenCode cannot consume Stop stdout, so its adapter must never wait there. */
747
+ /** The active harness selects the native continuation output adapter. */
645
748
  harness?: HookHarness
749
+ /**
750
+ * The local record. A hook's decisions are invisible from everywhere else —
751
+ * its stderr belongs to the harness and its usual outcome is to do nothing —
752
+ * so this is the only account of why a question did or did not travel.
753
+ */
754
+ log?: Logger
646
755
  }
647
756
 
648
- export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
757
+ /**
758
+ * Record one gate decision.
759
+ *
760
+ * `verdict` is what happened to the question and `reason` is the closed-set
761
+ * name of the rule that decided it, so an agent can ask "why was it held" with
762
+ * a filter rather than by matching on prose. The prose still exists — it is what
763
+ * the user sees in the terminal — but it is free to be rewritten, and a log a
764
+ * filter cannot rely on is a log nobody filters.
765
+ */
766
+ export type GateReason =
767
+ | 'no-session'
768
+ | 'no-question'
769
+ | 'answered'
770
+ | 'already-asked'
771
+ | 'continuation-repeat'
772
+ | 'continuation-limit'
773
+ | 'notifications-off'
774
+ | 'user-present'
775
+ | 'claimed-elsewhere'
776
+ | 'user-returned'
777
+ | 'no-devices'
778
+ | 'proceeding'
649
779
 
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[]
780
+ function gate(
781
+ ctx: HookContext,
782
+ verdict: 'held' | 'proceeding',
783
+ reason: GateReason,
784
+ data: Record<string, unknown> = {},
785
+ ): void {
786
+ ctx.log?.info('hook.gate', { verdict, reason, ...data })
655
787
  }
656
788
 
789
+ export type HookHarness = Harness
790
+
657
791
  /** Re-check local presence at this cadence while a pushed question is waiting. */
658
792
  const REPLY_PRESENCE_POLL_SECONDS = 5
659
793
 
@@ -672,7 +806,7 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
672
806
  * Put one registered question on the user's devices. Submission only — the
673
807
  * wait happens once, across every live question, in the caller.
674
808
  */
675
- async function submitQuestion(
809
+ async function prepareQuestionSubmission(
676
810
  ctx: HookContext,
677
811
  options: {
678
812
  title: string
@@ -684,8 +818,10 @@ async function submitQuestion(
684
818
  session?: string | undefined
685
819
  /** How long the server keeps accepting an answer. */
686
820
  windowSeconds: number
821
+ /** Absolute owner deadline persisted before submit. */
822
+ ownerDeadlineAt: number
687
823
  },
688
- ): Promise<SubmittedQuestion | { error: string }> {
824
+ ): Promise<PendingSubmissionIntent | { error: string }> {
689
825
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
690
826
  // A draft carrying `reply` is rejected outright if it targets a device that
691
827
  // cannot answer, so resolve the healthy companion platforms explicitly.
@@ -708,11 +844,34 @@ async function submitQuestion(
708
844
  })
709
845
  if (!build.ok) return { error: build.error }
710
846
 
847
+ return {
848
+ request_id: `req_${randomBytes(18).toString('base64url')}`,
849
+ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
850
+ collapse_key: collapseKey,
851
+ device_ids: answerable,
852
+ draft: build.draft,
853
+ owner_deadline_at: options.ownerDeadlineAt,
854
+ }
855
+ }
856
+
857
+ async function submitQuestion(
858
+ ctx: HookContext,
859
+ intent: PendingSubmissionIntent,
860
+ ): Promise<SubmissionReceipt> {
711
861
  const receipt = await ctx.client.submit(
712
- { idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
862
+ {
863
+ request_id: intent.request_id,
864
+ idempotency_key: intent.idempotency_key,
865
+ draft: intent.draft,
866
+ },
713
867
  0,
714
868
  )
715
- return { requestId: receipt.request_id, collapseKey, devices: answerable }
869
+ if (receipt.request_id !== intent.request_id) {
870
+ throw new Error(
871
+ `server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`,
872
+ )
873
+ }
874
+ return receipt
716
875
  }
717
876
 
718
877
  /**
@@ -725,22 +884,29 @@ async function waitForAnyReplyWhileAway(
725
884
  ctx: HookContext,
726
885
  requestIds: string[],
727
886
  timeoutSeconds: number,
728
- ): Promise<{ byRequest: Map<string, ReplyView[]>; degraded: boolean; userReturned: boolean }> {
887
+ monitorPresence = true,
888
+ ): Promise<{
889
+ byRequest: Map<string, ReplyView[]>
890
+ degraded: boolean
891
+ userReturned: boolean
892
+ permanentFailures: Map<string, string>
893
+ }> {
729
894
  const deadline = ctx.now() + timeoutSeconds * 1000
730
895
  let degraded = false
731
896
  let firstPoll = true
897
+ const permanentFailures = new Map<string, string>()
732
898
 
733
899
  for (;;) {
734
- if (ctx.config.require_idle.value) {
900
+ if (monitorPresence && ctx.config.require_idle.value) {
735
901
  const idle = ctx.idleSeconds()
736
902
  if (idle !== null && idle < ctx.config.away_after_seconds.value) {
737
- return { byRequest: new Map(), degraded, userReturned: true }
903
+ return { byRequest: new Map(), degraded, userReturned: true, permanentFailures }
738
904
  }
739
905
  }
740
906
 
741
907
  const remainingMs = Math.max(0, deadline - ctx.now())
742
908
  if (!firstPoll && remainingMs === 0) {
743
- return { byRequest: new Map(), degraded, userReturned: false }
909
+ return { byRequest: new Map(), degraded, userReturned: false, permanentFailures }
744
910
  }
745
911
  firstPoll = false
746
912
  const pollSeconds = Math.min(
@@ -748,23 +914,51 @@ async function waitForAnyReplyWhileAway(
748
914
  Math.max(0, Math.ceil(remainingMs / 1000)),
749
915
  )
750
916
  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
- }),
917
+ requestIds
918
+ .filter((requestId) => !permanentFailures.has(requestId))
919
+ .map(async (requestId) => {
920
+ try {
921
+ return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
922
+ } catch (err) {
923
+ if (isRetryableReplyPollError(err)) {
924
+ return { requestId, replies: [] as ReplyView[], degraded: true }
925
+ }
926
+ return {
927
+ requestId,
928
+ replies: [] as ReplyView[],
929
+ degraded: false,
930
+ permanentFailure: replyPollFailure(err),
931
+ }
932
+ }
933
+ }),
758
934
  )
759
935
  const byRequest = new Map<string, ReplyView[]>()
760
936
  for (const result of results) {
761
937
  degraded ||= result.degraded === true
938
+ if (result.permanentFailure !== undefined) {
939
+ permanentFailures.set(result.requestId, result.permanentFailure)
940
+ }
762
941
  if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
763
942
  }
764
- if (byRequest.size > 0) return { byRequest, degraded, userReturned: false }
943
+ if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
944
+ return { byRequest, degraded, userReturned: false, permanentFailures }
945
+ }
765
946
  }
766
947
  }
767
948
 
949
+ /** A non-retryable replies fault, with enough server identity to act on it. */
950
+ function replyPollFailure(err: unknown): string {
951
+ return err instanceof ApiCallError
952
+ ? `${err.code} (HTTP ${err.status}): ${err.message}`
953
+ : String(err)
954
+ }
955
+
956
+ function permanentReplyFailureNote(failures: Map<string, string>): string | null {
957
+ if (failures.size === 0) return null
958
+ const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ')
959
+ return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
960
+ }
961
+
768
962
  /**
769
963
  * Healthy companion devices that implement replies. Both the iOS app and the
770
964
  * macOS app register reply categories and submit answers directly.
@@ -806,15 +1000,28 @@ function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefi
806
1000
  */
807
1001
  export type RetireDeps = Pick<HookContext, 'client' | 'config'>
808
1002
 
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> {
1003
+ /**
1004
+ * Close is a server-side serialization fence: a successful response contains
1005
+ * every reply that committed before the window closed. `null` is deliberately
1006
+ * different from an empty reply set — it means ownership could not be proven
1007
+ * final and the local record must stay recoverable.
1008
+ */
1009
+ async function finalizeReplies(
1010
+ ctx: RetireDeps,
1011
+ requestId: string,
1012
+ ): Promise<ListRepliesResponse | null> {
811
1013
  try {
812
- await ctx.client.closeReplies(requestId)
1014
+ return await ctx.client.closeReplies(requestId)
813
1015
  } catch {
814
- // The window expires on its own; nothing here is worth failing a hook for.
1016
+ return null
815
1017
  }
816
1018
  }
817
1019
 
1020
+ /** Best effort only for already-journaled retirement debt. */
1021
+ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
1022
+ await finalizeReplies(ctx, requestId)
1023
+ }
1024
+
818
1025
  /**
819
1026
  * Retire the question on every device it reached. A state change is not news
820
1027
  * (D-B): the retirement rides as a background state sync — no alert, no
@@ -1086,7 +1293,7 @@ interface PendingPoll {
1086
1293
  /** Every reply in arrival order, for free-text answers given in parts. */
1087
1294
  replies: ReplyView[]
1088
1295
  degraded: boolean
1089
- failed: boolean
1296
+ permanentFailure: string | null
1090
1297
  }
1091
1298
 
1092
1299
  /** One bounded recovery poll for a question that outlived its original wait. */
@@ -1096,7 +1303,7 @@ async function pollPendingReply(
1096
1303
  timeoutSeconds: number,
1097
1304
  ): Promise<PendingPoll> {
1098
1305
  if (pending.request_id === undefined) {
1099
- return { reply: null, replies: [], degraded: false, failed: false }
1306
+ return { reply: null, replies: [], degraded: false, permanentFailure: null }
1100
1307
  }
1101
1308
  try {
1102
1309
  const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
@@ -1104,10 +1311,12 @@ async function pollPendingReply(
1104
1311
  reply: result.replies.at(-1) ?? null,
1105
1312
  replies: result.replies,
1106
1313
  degraded: result.degraded === true,
1107
- failed: false,
1314
+ permanentFailure: null,
1108
1315
  }
1109
- } catch {
1110
- return { reply: null, replies: [], degraded: true, failed: true }
1316
+ } catch (err) {
1317
+ return isRetryableReplyPollError(err)
1318
+ ? { reply: null, replies: [], degraded: true, permanentFailure: null }
1319
+ : { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) }
1111
1320
  }
1112
1321
  }
1113
1322
 
@@ -1118,6 +1327,48 @@ interface AnsweredPending {
1118
1327
  replies: ReplyView[]
1119
1328
  }
1120
1329
 
1330
+ interface FinalizedPending {
1331
+ pending: PendingQuestion
1332
+ response: ListRepliesResponse | null
1333
+ }
1334
+
1335
+ /** Close several windows concurrently without confusing failure with silence. */
1336
+ async function finalizePendings(
1337
+ ctx: HookContext,
1338
+ pending: PendingQuestion[],
1339
+ ): Promise<FinalizedPending[]> {
1340
+ return Promise.all(
1341
+ pending.map(async (entry) => ({
1342
+ pending: entry,
1343
+ response: await finalizeReplies(ctx, entry.request_id!),
1344
+ })),
1345
+ )
1346
+ }
1347
+
1348
+ /** Convert a fenced final reply set into the exact answer handed to the model. */
1349
+ function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1350
+ const replies = finalized.response?.replies ?? []
1351
+ const reply = replies.at(-1)
1352
+ return reply === undefined ? null : { pending: finalized.pending, reply, replies }
1353
+ }
1354
+
1355
+ /** Persist and surface one answer without letting the two representations drift. */
1356
+ function reportAnswer(
1357
+ ctx: HookContext,
1358
+ notes: string[],
1359
+ answered: AnsweredPending,
1360
+ late: boolean,
1361
+ ): void {
1362
+ ctx.log?.info('hook.answer', {
1363
+ answered: true,
1364
+ ...(late ? { late: true } : {}),
1365
+ request_id: answered.pending.request_id,
1366
+ device: answered.reply.device_name,
1367
+ text: answered.reply.text,
1368
+ })
1369
+ notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
1370
+ }
1371
+
1121
1372
  /**
1122
1373
  * One bounded recovery poll across every delivered question, concurrently —
1123
1374
  * N questions must not multiply the hook's latency budget by N.
@@ -1126,19 +1377,27 @@ async function pollPendingReplies(
1126
1377
  ctx: HookContext,
1127
1378
  live: PendingQuestion[],
1128
1379
  timeoutSeconds: number,
1129
- ): Promise<{ answered: AnsweredPending[]; troubled: boolean }> {
1380
+ ): Promise<{
1381
+ answered: AnsweredPending[]
1382
+ transientTrouble: boolean
1383
+ permanentFailures: Map<string, string>
1384
+ }> {
1130
1385
  const polls = await Promise.all(
1131
1386
  live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
1132
1387
  )
1133
1388
  const answered: AnsweredPending[] = []
1134
- let troubled = false
1389
+ let transientTrouble = false
1390
+ const permanentFailures = new Map<string, string>()
1135
1391
  for (const [index, poll] of polls.entries()) {
1136
1392
  if (poll.reply !== null) {
1137
1393
  answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
1138
1394
  }
1139
- if (poll.failed || poll.degraded) troubled = true
1395
+ if (poll.degraded) transientTrouble = true
1396
+ if (poll.permanentFailure !== null) {
1397
+ permanentFailures.set(live[index]!.request_id!, poll.permanentFailure)
1398
+ }
1140
1399
  }
1141
- return { answered, troubled }
1400
+ return { answered, transientTrouble, permanentFailures }
1142
1401
  }
1143
1402
 
1144
1403
  /** The registered-question queue. Anything but the current shape reads as empty. */
@@ -1148,6 +1407,9 @@ function pendingList(state: SessionState): PendingQuestion[] {
1148
1407
 
1149
1408
  /** Registration identity — the convention every racing writer compares by. */
1150
1409
  function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1410
+ if (a.question_id !== undefined || b.question_id !== undefined) {
1411
+ return a.question_id !== undefined && a.question_id === b.question_id
1412
+ }
1151
1413
  return a.question === b.question && a.asked_at === b.asked_at
1152
1414
  }
1153
1415
 
@@ -1208,33 +1470,50 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
1208
1470
  return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`
1209
1471
  }
1210
1472
 
1211
- function userPromptAnswerOutput(context: string): string {
1473
+ function stopAnswerOutput(context: string): string {
1212
1474
  return JSON.stringify({
1213
- hookSpecificOutput: {
1214
- hookEventName: 'UserPromptSubmit',
1215
- additionalContext: context,
1216
- },
1475
+ decision: 'block',
1476
+ reason: context,
1217
1477
  })
1218
1478
  }
1219
1479
 
1220
- function stopAnswerOutput(context: string): string {
1221
- return JSON.stringify({ decision: 'block', reason: context })
1222
- }
1223
-
1224
1480
  /**
1225
1481
  * Close, truthfully retire, and forget every answered pending question in one
1226
1482
  * state write, without dropping any other retirement debt or any question
1227
1483
  * still waiting. Stop answers also open one bounded continuation generation;
1228
1484
  * UserPromptSubmit answers ride the user's new turn.
1229
1485
  */
1230
- async function finishAnsweredPendings(
1486
+ function stageAcceptedAnswers(
1231
1487
  ctx: HookContext,
1232
- envelope: HookEnvelope,
1233
1488
  sessionId: string,
1234
1489
  answered: AnsweredPending[],
1235
- continuation: boolean,
1236
- ): Promise<void> {
1237
- const retirements = answered
1490
+ remaining: number,
1491
+ ): void {
1492
+ updateSessionState(sessionId, ctx.env, (current) => {
1493
+ const pendingRemaining = pendingList(current).filter(
1494
+ (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1495
+ )
1496
+ const next: SessionState = {
1497
+ ...current,
1498
+ accepted: {
1499
+ answers: answered,
1500
+ remaining,
1501
+ recorded_at: ctx.now(),
1502
+ },
1503
+ }
1504
+ if (pendingRemaining.length > 0) next.pending = pendingRemaining
1505
+ else delete next.pending
1506
+ return next
1507
+ })
1508
+ }
1509
+
1510
+ /** Settle only after a successor hook proves the harness received the answer. */
1511
+ function settleAcceptedAnswers(
1512
+ ctx: HookContext,
1513
+ sessionId: string,
1514
+ accepted: AcceptedAnswerDelivery,
1515
+ ): void {
1516
+ const retirements = accepted.answers
1238
1517
  .map(({ pending }) => retiringQuestion(pending, 'answered'))
1239
1518
  .filter((entry): entry is RetiringQuestion => entry !== null)
1240
1519
  updateSessionState(sessionId, ctx.env, (current) => {
@@ -1244,24 +1523,44 @@ async function finishAnsweredPendings(
1244
1523
  if (existing < 0) retiring.push(retirement)
1245
1524
  else retiring[existing] = retirement
1246
1525
  }
1247
- const remaining = pendingList(current).filter(
1248
- (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1249
- )
1250
1526
  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,
1527
+ delete next.accepted
1528
+ next.continuation = {
1529
+ answered_at: accepted.recorded_at,
1530
+ count: accepted.continuation_count ?? 1,
1531
+ }
1532
+ return next
1533
+ })
1534
+ }
1535
+
1536
+ async function retirePendings(
1537
+ ctx: HookContext,
1538
+ envelope: HookEnvelope,
1539
+ sessionId: string,
1540
+ entries: PendingQuestion[],
1541
+ state: LifecycleEndState,
1542
+ ): Promise<void> {
1543
+ updateSessionState(sessionId, ctx.env, (current) => {
1544
+ const retiring = [...(current.retiring ?? [])]
1545
+ for (const entry of entries) {
1546
+ const retirement = retiringQuestion(entry, state)
1547
+ if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1548
+ retiring.push(retirement)
1258
1549
  }
1259
- } else {
1260
- next.last_prompt_at = ctx.now()
1261
1550
  }
1551
+ const pending = pendingList(current).filter(
1552
+ (candidate) => !entries.some((entry) => isSamePending(candidate, entry)),
1553
+ )
1554
+ const next: SessionState = { ...current }
1555
+ if (pending.length > 0) next.pending = pending
1556
+ else delete next.pending
1557
+ if (retiring.length > 0) next.retiring = retiring
1558
+ else delete next.retiring
1262
1559
  return next
1263
1560
  })
1264
- await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1561
+ // Network retirement is deliberately deferred to a later no-question hook.
1562
+ // The state write above is the durable operation; blocking here creates a
1563
+ // crash window before an answer can reach harness stdout.
1265
1564
  }
1266
1565
 
1267
1566
  // ---------------------------------------------------------------------------
@@ -1283,27 +1582,63 @@ export async function handleUserPromptSubmit(
1283
1582
  if (!sessionId) return { notes }
1284
1583
 
1285
1584
  const state = readSessionState(sessionId, ctx.env)
1585
+ if (state.accepted !== undefined) {
1586
+ updateSessionState(sessionId, ctx.env, (current) => ({
1587
+ ...current,
1588
+ last_prompt_at: ctx.now(),
1589
+ }))
1590
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1591
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1592
+ }
1593
+ notes.push('a device answer is safely journaled; the next Stop will deliver it')
1594
+ return { notes }
1595
+ }
1286
1596
  const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
1287
1597
  let lateAnswers: AnsweredPending[] = []
1288
1598
  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}`)
1599
+ const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(
1600
+ ctx,
1601
+ live,
1602
+ LATE_PROMPT_POLL_SECONDS,
1603
+ )
1604
+ const finalized = await finalizePendings(
1605
+ ctx,
1606
+ answered.map((entry) => entry.pending),
1607
+ )
1608
+ lateAnswers = answered.map((entry) => {
1609
+ const response = finalized.find((candidate) =>
1610
+ isSamePending(candidate.pending, entry.pending),
1611
+ )?.response
1612
+ const replies = response?.replies.length ? response.replies : entry.replies
1613
+ if (response === null) {
1614
+ notes.push(
1615
+ `could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
1616
+ )
1295
1617
  }
1618
+ return { ...entry, replies, reply: replies.at(-1)! }
1619
+ })
1620
+ if (answered.length > 0) {
1621
+ stageAcceptedAnswers(
1622
+ ctx,
1623
+ sessionId,
1624
+ lateAnswers,
1625
+ 0,
1626
+ )
1627
+ for (const answer of lateAnswers) reportAnswer(ctx, notes, answer, true)
1296
1628
  }
1297
- if (troubled) {
1629
+ if (transientTrouble) {
1298
1630
  notes.push('could not check every pending question for a late answer before the prompt')
1299
1631
  }
1632
+ const permanentFailure = permanentReplyFailureNote(permanentFailures)
1633
+ if (permanentFailure !== null) notes.push(permanentFailure)
1300
1634
  }
1301
1635
  // Park before dropping `pending`. If the process dies between these writes,
1302
1636
  // the next hook sees both copies and dedupes them; the old order could die in
1303
1637
  // the gap after erasing the only request/collapse/device identifiers.
1304
1638
  updateSessionState(sessionId, ctx.env, (current) => {
1305
1639
  const retiring = [...(current.retiring ?? [])]
1306
- for (const entry of pendingList(current)) {
1640
+ const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
1641
+ for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
1307
1642
  const retirement = retiringQuestion(entry, 'answered_elsewhere')
1308
1643
  if (
1309
1644
  retirement !== null &&
@@ -1312,7 +1647,14 @@ export async function handleUserPromptSubmit(
1312
1647
  retiring.push(retirement)
1313
1648
  }
1314
1649
  }
1315
- return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) }
1650
+ return {
1651
+ last_prompt_at: ctx.now(),
1652
+ ...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
1653
+ ...(unasked.length > 0 ? { pending: unasked } : {}),
1654
+ ...(retiring.length > 0 ? { retiring } : {}),
1655
+ ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1656
+ ...(current.continuation === undefined ? {} : { continuation: current.continuation }),
1657
+ }
1316
1658
  })
1317
1659
  // The bridge that lets a plain `notifai ask` find the hook's canonical
1318
1660
  // session: an agent shell command gets no hook payload, and not every
@@ -1321,17 +1663,19 @@ export async function handleUserPromptSubmit(
1321
1663
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1322
1664
  }
1323
1665
 
1666
+ if (lateAnswers.length > 0) {
1667
+ // UserPromptSubmit has no later hook field proving its stdout reached the
1668
+ // model. Keep the journal and let the next Stop use its acknowledged
1669
+ // continuation channel instead of recreating the crash-before-stdout gap.
1670
+ notes.push('the late device answer will continue the agent at this turn’s Stop')
1671
+ return { notes }
1672
+ }
1324
1673
  const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1325
1674
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1326
1675
  const swept = [...retired, ...orphaned]
1327
1676
  if (swept.length > 0) {
1328
1677
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1329
1678
  }
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
1679
  return { notes }
1336
1680
  }
1337
1681
 
@@ -1339,66 +1683,199 @@ export async function handleUserPromptSubmit(
1339
1683
  // Stop — the turn ended; escalate a registered question
1340
1684
  // ---------------------------------------------------------------------------
1341
1685
 
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
1686
  /**
1350
1687
  * Only engages for a question the agent explicitly registered with
1351
1688
  * `notifai ask`. Guessing from the last assistant message was the alternative
1352
1689
  * and it is not worth it: a false positive here hijacks the terminal.
1353
1690
  */
1354
- export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Promise<HookOutcome> {
1691
+ export async function handleStop(
1692
+ ctx: HookContext,
1693
+ envelope: HookEnvelope,
1694
+ processDeadlineAt = ctx.now() + STOP_BUDGET_SECONDS * 1000,
1695
+ ): Promise<HookOutcome> {
1355
1696
  const notes: string[] = []
1697
+ const hardDeadlineAt = processDeadlineAt
1356
1698
  const sessionId = envelope.session_id
1357
- if (!sessionId) return { notes }
1699
+ if (!sessionId) {
1700
+ gate(ctx, 'held', 'no-session')
1701
+ return { notes }
1702
+ }
1358
1703
 
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(', ')}`)
1704
+ // This event marker is diagnostic evidence in its own right. Record it
1705
+ // before every early return so doctor can distinguish a working Stop route
1706
+ // from the prompt-only state that previously looked healthy.
1707
+ updateSessionState(sessionId, ctx.env, (current) => ({
1708
+ ...current,
1709
+ last_stop_at: ctx.now(),
1710
+ }))
1711
+
1712
+ // Claim before any reply poll, not only before the grace window. Two racing
1713
+ // Stops can both observe the same accepted journal or live request. An
1714
+ // atomic state update cannot retract duplicate block output a loser already
1715
+ // built, so one session claim owns the complete Stop decision: accepted
1716
+ // replay/ack, late-answer collection, cleanup, and any new escalation.
1717
+ //
1718
+ // Real clock, deliberately, not `ctx.now` — the claim answers "is another
1719
+ // process alive right now", which the injectable clock cannot speak to. It
1720
+ // is also the only clock the *other* process shares.
1721
+ if (!claimQuestionPush(sessionId, ctx.env)) {
1722
+ gate(ctx, 'held', 'claimed-elsewhere')
1723
+ notes.push('another hook is already handling this question')
1724
+ return { notes }
1370
1725
  }
1726
+ try {
1727
+ let state = readSessionState(sessionId, ctx.env)
1728
+ if (state.accepted !== undefined) {
1729
+ const accepted = state.accepted
1730
+ // A recursive Stop is emitted only after the previous Stop's blocking
1731
+ // output was admitted and the continued model turn ran. That event is
1732
+ // the acknowledgment: persisting a separate "offered" bit before stdout
1733
+ // would recreate the crash gap this journal exists to close.
1734
+ const deliveryProven = envelope.stop_hook_active === true
1735
+ if (!deliveryProven) {
1736
+ updateSessionState(sessionId, ctx.env, (current) => {
1737
+ if (current.accepted === undefined) return current
1738
+ return {
1739
+ ...current,
1740
+ accepted: {
1741
+ ...current.accepted,
1742
+ continuation_count: (current.continuation?.count ?? 0) + 1,
1743
+ },
1744
+ }
1745
+ })
1746
+ for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
1747
+ return {
1748
+ stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
1749
+ notes,
1750
+ }
1751
+ }
1752
+ settleAcceptedAnswers(ctx, sessionId, accepted)
1753
+ state = readSessionState(sessionId, ctx.env)
1754
+ }
1371
1755
 
1372
- const state = readSessionState(sessionId, ctx.env)
1373
- const pending = pendingList(state)
1374
- if (pending.length === 0) return { notes }
1756
+ const pending = pendingList(state)
1757
+ if (pending.length === 0) {
1758
+ const swept = [
1759
+ ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
1760
+ ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
1761
+ ]
1762
+ if (swept.length > 0) {
1763
+ notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1764
+ }
1765
+ gate(ctx, 'held', 'no-question')
1766
+ return { notes }
1767
+ }
1768
+
1769
+ return await handleClaimedStop(
1770
+ ctx,
1771
+ envelope,
1772
+ sessionId,
1773
+ state,
1774
+ pending,
1775
+ notes,
1776
+ hardDeadlineAt,
1777
+ )
1778
+ } finally {
1779
+ releaseQuestionPush(sessionId, ctx.env)
1780
+ }
1781
+ }
1782
+
1783
+ /** The complete per-session Stop decision, after this process owns the claim. */
1784
+ async function handleClaimedStop(
1785
+ ctx: HookContext,
1786
+ envelope: HookEnvelope,
1787
+ sessionId: string,
1788
+ state: SessionState,
1789
+ pending: PendingQuestion[],
1790
+ notes: string[],
1791
+ hardDeadlineAt: number,
1792
+ ): Promise<HookOutcome> {
1375
1793
  const live = pending.filter((entry) => entry.request_id !== undefined)
1376
1794
  const unasked = pending.filter((entry) => entry.request_id === undefined)
1795
+ let liveToEscalate = live
1796
+
1797
+ if (
1798
+ ctx.harness !== undefined &&
1799
+ HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported'
1800
+ ) {
1801
+ for (const entry of live) await closeQuietly(ctx, entry.request_id!)
1802
+ await retirePendings(ctx, envelope, sessionId, pending, 'expired')
1803
+ gate(ctx, 'held', 'no-question')
1804
+ notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
1805
+ return { notes }
1806
+ }
1377
1807
 
1378
1808
  if (live.length > 0) {
1379
- const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS)
1809
+ const { answered, permanentFailures } = await pollPendingReplies(
1810
+ ctx,
1811
+ live,
1812
+ LATE_STOP_POLL_SECONDS,
1813
+ )
1814
+ const permanentFailure = permanentReplyFailureNote(permanentFailures)
1815
+ if (permanentFailure !== null) notes.push(permanentFailure)
1816
+ const permanentlyRejected = live.filter((entry) =>
1817
+ permanentFailures.has(entry.request_id!),
1818
+ )
1819
+ await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id!)))
1820
+ if (permanentlyRejected.length > 0) {
1821
+ await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired')
1822
+ }
1380
1823
  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
- }
1824
+ const finalized = await finalizePendings(
1825
+ ctx,
1826
+ answered.map((entry) => entry.pending),
1827
+ )
1828
+ const authoritative = answered.map((entry) => {
1829
+ const response = finalized.find((candidate) =>
1830
+ isSamePending(candidate.pending, entry.pending),
1831
+ )?.response
1832
+ const replies = response?.replies.length ? response.replies : entry.replies
1833
+ if (response === null) {
1834
+ notes.push(
1835
+ `could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
1836
+ )
1837
+ }
1838
+ return { ...entry, replies, reply: replies.at(-1)! }
1839
+ })
1840
+ stageAcceptedAnswers(
1841
+ ctx,
1842
+ sessionId,
1843
+ authoritative,
1844
+ pending.length - authoritative.length - permanentlyRejected.length,
1845
+ )
1846
+ gate(ctx, 'proceeding', 'answered', { answers: authoritative.length })
1847
+ for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
1385
1848
  // Anything still unasked rides the next Stop: the agent is being resumed
1386
1849
  // with answers right now, and may not even need the rest afterwards.
1387
1850
  return {
1388
- stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
1851
+ stdout: stopAnswerOutput(
1852
+ answersContext(
1853
+ authoritative,
1854
+ pending.length - authoritative.length - permanentlyRejected.length,
1855
+ ),
1856
+ ),
1389
1857
  notes,
1390
1858
  }
1391
1859
  }
1860
+ const recoverableLive = live.filter(
1861
+ (entry) => !permanentFailures.has(entry.request_id!),
1862
+ )
1863
+ liveToEscalate = recoverableLive
1392
1864
  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'}`,
1865
+ if (recoverableLive.length === 0) return { notes }
1866
+ // A previous answer may have resumed the model while independent
1867
+ // questions remain. The next Stop is their successor owner: keep waiting
1868
+ // to the original absolute deadline instead of polling for four seconds
1869
+ // and abandoning them.
1870
+ return await escalate(
1871
+ ctx,
1872
+ envelope,
1873
+ sessionId,
1874
+ [],
1875
+ recoverableLive,
1876
+ notes,
1877
+ hardDeadlineAt,
1400
1878
  )
1401
- return { notes }
1402
1879
  }
1403
1880
  // Questions registered after the earlier push still owe the user their
1404
1881
  // notification; fall through and escalate just those.
@@ -1415,36 +1892,55 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1415
1892
  (entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
1416
1893
  )
1417
1894
  if (!isNew) {
1895
+ gate(ctx, 'held', 'continuation-repeat')
1418
1896
  notes.push('already continuing from an answer; not asking again this turn')
1419
1897
  return { notes }
1420
1898
  }
1421
1899
  if (continuation.count >= MAX_CONTINUATION_COUNT) {
1900
+ gate(ctx, 'held', 'continuation-limit', {
1901
+ count: continuation.count,
1902
+ limit: MAX_CONTINUATION_COUNT,
1903
+ })
1422
1904
  notes.push(
1423
1905
  `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
1424
1906
  )
1425
1907
  return { notes }
1426
1908
  }
1427
1909
  }
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')
1910
+ // Silent to the user by design — they switched routing off, so saying so on
1911
+ // every turn would be nagging about their own setting. That silence is also
1912
+ // why it belongs in the log: from outside, "ask_notifications = false" and "a
1913
+ // bug ate my question" look exactly the same.
1914
+ if (!ctx.config.ask_notifications.value) {
1915
+ gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
1431
1916
  return { notes }
1432
1917
  }
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')
1918
+ const idle = ctx.idleSeconds()
1919
+ if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
1920
+ gate(ctx, 'held', 'user-present', {
1921
+ idle_seconds: idle,
1922
+ away_after_seconds: ctx.config.away_after_seconds.value,
1923
+ require_idle: ctx.config.require_idle.value,
1924
+ last_prompt_at: state.last_prompt_at ?? null,
1925
+ })
1926
+ notes.push('you are at the keyboard; leaving the question in the terminal')
1441
1927
  return { notes }
1442
1928
  }
1443
- try {
1444
- return await escalate(ctx, envelope, sessionId, unasked, live, notes)
1445
- } finally {
1446
- releaseQuestionPush(sessionId, ctx.env)
1447
- }
1929
+ gate(ctx, 'proceeding', 'proceeding', {
1930
+ unasked: unasked.length,
1931
+ already_live: liveToEscalate.length,
1932
+ idle_seconds: idle,
1933
+ grace_seconds: ctx.config.ask_grace_seconds.value,
1934
+ })
1935
+ return await escalate(
1936
+ ctx,
1937
+ envelope,
1938
+ sessionId,
1939
+ unasked,
1940
+ liveToEscalate,
1941
+ notes,
1942
+ hardDeadlineAt,
1943
+ )
1448
1944
  }
1449
1945
 
1450
1946
  /** The escalation itself, split out so the claim is released on every path. */
@@ -1455,133 +1951,367 @@ async function escalate(
1455
1951
  unasked: PendingQuestion[],
1456
1952
  alreadyLive: PendingQuestion[],
1457
1953
  notes: string[],
1954
+ hardDeadlineAt: number,
1458
1955
  ): Promise<HookOutcome> {
1459
1956
  // Away right now, but the questions still owe the user their terminal-first
1460
1957
  // window before anything reaches their devices — measured from the oldest
1461
1958
  // 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')
1959
+ const budgetStartedAt = ctx.now()
1960
+ const existingDeadlines = alreadyLive.flatMap((entry) =>
1961
+ entry.reply_deadline_at === undefined || entry.reply_deadline_at <= budgetStartedAt
1962
+ ? []
1963
+ : [entry.reply_deadline_at],
1964
+ )
1965
+ const budgetDeadlineAt = Math.min(
1966
+ hardDeadlineAt - HOOK_TIMING.finalizationReserveSeconds * 1000,
1967
+ ...(existingDeadlines.length === 0 ? [] : existingDeadlines),
1968
+ )
1969
+ if (unasked.length > 0 && alreadyLive.length === 0) {
1970
+ const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
1971
+ const grace = await awaitGrace(ctx, oldest)
1972
+ ctx.log?.debug('hook.gate', { verdict: 'grace', reason: grace, waited_from: oldest })
1973
+ if (grace === 'user-returned') {
1974
+ gate(ctx, 'held', 'user-returned', { grace_seconds: ctx.config.ask_grace_seconds.value })
1975
+ notes.push('you came back before the wait elapsed; leaving the questions in the terminal')
1976
+ return { notes }
1977
+ }
1978
+ if (grace === 'no-signal') {
1979
+ notes.push('no idle signal on this machine; asking now rather than holding the terminal')
1980
+ }
1470
1981
  }
1471
-
1472
1982
  // Phase one: every registered question reaches the user's devices, each as
1473
1983
  // its own notification — one ask never stands in for another.
1474
1984
  const submitted: PendingQuestion[] = []
1475
1985
  for (const entry of unasked) {
1986
+ const remainingSeconds = Math.floor((budgetDeadlineAt - ctx.now()) / 1000)
1987
+ if (
1988
+ remainingSeconds <
1989
+ MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds
1990
+ ) {
1991
+ notes.push(
1992
+ 'the Stop budget cannot fit device resolution, submission, and a full reply window; leaving this question in the terminal',
1993
+ )
1994
+ continue
1995
+ }
1996
+ const replyWindowSeconds = Math.min(
1997
+ ctx.config.hook_reply_timeout_seconds.value,
1998
+ remainingSeconds - HOOK_TIMING.submissionReserveSeconds,
1999
+ )
2000
+ const ownerDeadlineAt = Math.min(
2001
+ budgetDeadlineAt,
2002
+ ctx.now() +
2003
+ (replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000,
2004
+ )
1476
2005
  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)
2006
+ let intent = entry.submission
2007
+ if (intent === undefined) {
2008
+ const prepared = await prepareQuestionSubmission(ctx, {
2009
+ title:
2010
+ ctx.config.project.value === null
2011
+ ? 'Question'
2012
+ : `Question · ${ctx.config.project.value}`,
2013
+ // A set is answered on the expanded card; the banner leads with the
2014
+ // first question and says how much more is waiting behind it.
2015
+ body:
2016
+ questions.length > 1
2017
+ ? `${questions[0]!.text} (+${questions.length - 1} more)`
2018
+ : entry.question,
2019
+ questions,
2020
+ ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
2021
+ event: 'agent_question',
2022
+ session: sessionLabel(ctx, envelope),
2023
+ windowSeconds: replyWindowSeconds,
2024
+ ownerDeadlineAt,
2025
+ })
2026
+ if ('error' in prepared) {
2027
+ ctx.log?.error('hook.pushed', { ok: false, message: prepared.error })
2028
+ notes.push(prepared.error)
2029
+ continue
2030
+ }
2031
+ intent = prepared
2032
+ // Durable before submit. If the server commits and the response is lost,
2033
+ // the reserved request id still lets this owner poll and finalize the
2034
+ // exact card; a successor can also replay the frozen draft/key.
2035
+ updateSessionState(sessionId, ctx.env, (current) => {
2036
+ const list = pendingList(current)
2037
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
2038
+ if (index < 0) return current
2039
+ const next = [...list]
2040
+ next[index] = { ...next[index]!, submission: prepared }
2041
+ return { ...current, pending: next }
2042
+ })
2043
+ } else if (intent.owner_deadline_at <= ctx.now()) {
2044
+ // The frozen wire identity survives a crashed owner, but its local owner
2045
+ // lease does not. Re-arm only the local deadline; request id, key,
2046
+ // targets, and draft remain byte-identical for idempotent replay.
2047
+ const frozenReplyWindow =
2048
+ intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds
2049
+ intent = {
2050
+ ...intent,
2051
+ owner_deadline_at: Math.min(
2052
+ budgetDeadlineAt,
2053
+ ctx.now() +
2054
+ (frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000,
2055
+ ),
2056
+ }
2057
+ const rearmed = intent
2058
+ updateSessionState(sessionId, ctx.env, (current) => {
2059
+ const list = pendingList(current)
2060
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
2061
+ if (index < 0) return current
2062
+ const next = [...list]
2063
+ next[index] = { ...next[index]!, submission: rearmed }
2064
+ return { ...current, pending: next }
2065
+ })
2066
+ }
2067
+ if (ctx.now() >= intent.owner_deadline_at) {
2068
+ notes.push('the Stop budget ended before submission; preserving the frozen intent')
1498
2069
  continue
1499
2070
  }
2071
+ const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
2072
+ if (
2073
+ beforeSubmitSeconds <
2074
+ MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2
2075
+ ) {
2076
+ notes.push(
2077
+ 'device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window',
2078
+ )
2079
+ continue
2080
+ }
2081
+ let receipt: SubmissionReceipt | undefined
2082
+ let admissionConfirmed = false
2083
+ try {
2084
+ receipt = await submitQuestion(ctx, intent)
2085
+ admissionConfirmed = true
2086
+ } catch (err) {
2087
+ if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
2088
+ // A concrete client response is an admission rejection, not an
2089
+ // ambiguous commit. Keep the frozen intent for a later retry and leave
2090
+ // the question in the terminal; polling a request the server says it
2091
+ // did not accept only burns the owner window and hides the real fault.
2092
+ notes.push(
2093
+ `question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
2094
+ )
2095
+ ctx.log?.error('hook.pushed', {
2096
+ ok: false,
2097
+ request_id: intent.request_id,
2098
+ status: err.status,
2099
+ code: err.code,
2100
+ message: err.message,
2101
+ })
2102
+ continue
2103
+ }
2104
+ // This is deliberately not a fail-open return. A transport failure is
2105
+ // ambiguous: the server may already have dispatched the card. The
2106
+ // client-generated request id keeps polling/finalization possible.
2107
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
2108
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
2109
+ }
2110
+ if (admissionConfirmed) {
2111
+ ctx.log?.info('hook.pushed', {
2112
+ ok: true,
2113
+ request_id: intent.request_id,
2114
+ devices: intent.device_ids.length,
2115
+ questions: questions.length,
2116
+ text: questions[0]!.text,
2117
+ })
2118
+ }
2119
+ const committedReplyDeadline =
2120
+ receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
2121
+ ? intent.owner_deadline_at
2122
+ : Date.parse(receipt.reply_expires_at)
1500
2123
  const live: PendingQuestion = {
1501
2124
  ...entry,
1502
- request_id: sent.requestId,
1503
- collapse_key: sent.collapseKey,
1504
- device_ids: sent.devices,
2125
+ request_id: intent.request_id,
2126
+ collapse_key: intent.collapse_key,
2127
+ device_ids: intent.device_ids,
2128
+ // Submission and every later poll share one wall-clock owner. The
2129
+ // server may start its relative window a little later while the submit
2130
+ // response is in flight; closing at this deadline is therefore the
2131
+ // authoritative backstop against accepting an answer into a void.
2132
+ reply_deadline_at: Math.min(
2133
+ intent.owner_deadline_at,
2134
+ Number.isFinite(committedReplyDeadline)
2135
+ ? committedReplyDeadline
2136
+ : intent.owner_deadline_at,
2137
+ ),
1505
2138
  }
2139
+ delete live.submission
1506
2140
  submitted.push(live)
1507
2141
  // Record what is now live on the user's devices BEFORE any wait. If we
1508
2142
  // only learned these ids afterwards, a question that timed out would
1509
2143
  // leave no trace, and the user returning to the terminal could never
1510
2144
  // retire it — the notification would stay answerable for an hour with
1511
2145
  // 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
- })
2146
+ if (admissionConfirmed) {
2147
+ updateSessionState(sessionId, ctx.env, (current) => {
2148
+ const list = pendingList(current)
2149
+ const index = list.findIndex(
2150
+ (candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
2151
+ )
2152
+ if (index >= 0) {
2153
+ const next = [...list]
2154
+ next[index] = live
2155
+ return { ...current, pending: next }
2156
+ }
2157
+ // The entry vanished while the submit was in flight (the user's prompt
2158
+ // wiped the queue). The delivered notification must still be retirable,
2159
+ // so park it rather than lose its only identifiers.
2160
+ const retirement = retiringQuestion(live, 'answered_elsewhere')!
2161
+ const retiring = [...(current.retiring ?? [])]
2162
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
2163
+ retiring.push(retirement)
2164
+ }
2165
+ return { ...current, retiring }
2166
+ })
2167
+ }
1532
2168
  }
1533
2169
 
1534
- const waitingOn = [...alreadyLive, ...submitted]
1535
- if (waitingOn.length === 0) return { notes }
1536
-
1537
- if (ctx.harness === 'opencode') {
2170
+ const staleLive = alreadyLive.filter(
2171
+ (entry) =>
2172
+ entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now(),
2173
+ )
2174
+ const finalizedStale = await finalizePendings(ctx, staleLive)
2175
+ const staleAnswers = finalizedStale
2176
+ .map(finalizedAnswer)
2177
+ .filter((entry): entry is AnsweredPending => entry !== null)
2178
+ const staleSilence = finalizedStale
2179
+ .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
2180
+ .map((entry) => entry.pending)
2181
+ const staleUnproven = finalizedStale.filter((entry) => entry.response === null)
2182
+ if (staleSilence.length > 0) {
2183
+ await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired')
1538
2184
  notes.push(
1539
- `question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`,
2185
+ `expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`,
1540
2186
  )
1541
- return { notes }
1542
2187
  }
2188
+ if (staleUnproven.length > 0) {
2189
+ notes.push(
2190
+ `could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`,
2191
+ )
2192
+ }
2193
+ if (staleAnswers.length > 0) {
2194
+ const remaining = Math.max(
2195
+ 0,
2196
+ pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
2197
+ )
2198
+ stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
2199
+ for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
2200
+ return {
2201
+ stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
2202
+ notes,
2203
+ }
2204
+ }
2205
+ const waitingOn = [
2206
+ ...alreadyLive.filter(
2207
+ (entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now(),
2208
+ ),
2209
+ ...submitted,
2210
+ ]
2211
+ if (waitingOn.length === 0) return { notes }
1543
2212
 
1544
2213
  // Phase two: one blocking wait across everything live, old and new alike.
1545
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
2214
+ const ownerDeadline = Math.min(
2215
+ budgetDeadlineAt,
2216
+ Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
2217
+ )
2218
+ const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
1546
2219
  const waited = await waitForAnyReplyWhileAway(
1547
2220
  ctx,
1548
2221
  waitingOn.map((entry) => entry.request_id!),
1549
2222
  timeoutSeconds,
2223
+ ctx.harness !== 'opencode',
1550
2224
  )
2225
+ const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
2226
+ if (permanentFailure !== null) notes.push(permanentFailure)
1551
2227
 
1552
2228
  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(', ')
2229
+ // Once this owner returns, no shipped command-hook harness can guarantee
2230
+ // a later answer reaches the same agent turn. Close first, then retire the
2231
+ // local records, so the Companion never accepts an answer into a void.
2232
+ const finalized = await finalizePendings(ctx, waitingOn)
2233
+ const finalAnswers = finalized
2234
+ .map(finalizedAnswer)
2235
+ .filter((entry): entry is AnsweredPending => entry !== null)
2236
+ const confirmedSilent = finalized
2237
+ .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
2238
+ .map((entry) => entry.pending)
2239
+ const unproven = finalized.filter((entry) => entry.response === null)
2240
+ if (confirmedSilent.length > 0) {
2241
+ await retirePendings(
2242
+ ctx,
2243
+ envelope,
2244
+ sessionId,
2245
+ confirmedSilent,
2246
+ waited.userReturned ? 'answered_elsewhere' : 'expired',
2247
+ )
2248
+ }
2249
+ if (unproven.length > 0) {
2250
+ notes.push(
2251
+ `could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`,
2252
+ )
2253
+ }
2254
+ if (finalAnswers.length > 0) {
2255
+ const remaining = Math.max(
2256
+ 0,
2257
+ pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
2258
+ )
2259
+ stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
2260
+ for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
2261
+ return {
2262
+ stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
2263
+ notes,
2264
+ }
2265
+ }
2266
+ const requestIdSummary = summarizeRequestIds(waitingOn)
2267
+ ctx.log?.info('hook.answer', {
2268
+ answered: false,
2269
+ request_ids: requestIdSummary.ids,
2270
+ user_returned: waited.userReturned,
2271
+ degraded: waited.degraded,
2272
+ permanent_failures: waited.permanentFailures.size,
2273
+ waited_seconds: timeoutSeconds,
2274
+ })
1557
2275
  if (waited.userReturned) {
1558
2276
  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',
2277
+ `you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; ${waitingOn.length === 1 ? 'it was' : 'they were'} retired before returning the terminal (${requestIdSummary.display})`,
1561
2278
  )
1562
- } else {
2279
+ } else if (waited.permanentFailures.size === 0) {
1563
2280
  notes.push(
1564
2281
  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',
2282
+ ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
2283
+ : 'no answer in time; the question expired with its continuation owner',
1567
2284
  )
1568
2285
  }
1569
2286
  return { notes }
1570
2287
  }
1571
2288
 
2289
+ const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id!))
2290
+ const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
1572
2291
  const answered: AnsweredPending[] = []
1573
- for (const entry of waitingOn) {
2292
+ for (const finalized of finalizedAnswered) {
2293
+ const entry = finalized.pending
1574
2294
  const replies = waited.byRequest.get(entry.request_id!)
1575
2295
  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}`)
2296
+ // The fenced set wins because it includes corrections that committed
2297
+ // while close was waiting for earlier writers. If finalization itself was
2298
+ // unreachable, never discard the durable reply already observed.
2299
+ const finalReplies = finalized.response?.replies ?? []
2300
+ const authoritative = finalReplies.length > 0 ? finalReplies : replies
2301
+ answered.push({ pending: entry, reply: authoritative.at(-1)!, replies: authoritative })
2302
+ if (finalized.response === null) {
2303
+ notes.push(
2304
+ `could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
2305
+ )
2306
+ }
1584
2307
  }
2308
+ stageAcceptedAnswers(
2309
+ ctx,
2310
+ sessionId,
2311
+ answered,
2312
+ waitingOn.length - answered.length,
2313
+ )
2314
+ for (const answer of answered) reportAnswer(ctx, notes, answer, false)
1585
2315
  return {
1586
2316
  stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1587
2317
  notes,
@@ -1607,7 +2337,7 @@ export function handleSessionEnd(
1607
2337
  ): HookOutcome {
1608
2338
  const notes: string[] = []
1609
2339
  const sessionId = envelope.session_id
1610
- if (!sessionId) return { notes }
2340
+ if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
1611
2341
  if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
1612
2342
 
1613
2343
  const state = readSessionState(sessionId, env)
@@ -1620,7 +2350,10 @@ export function handleSessionEnd(
1620
2350
  notes.push(err instanceof Error ? err.message : String(err))
1621
2351
  // Preserve the only identifiers instead of turning an explicit corrupt
1622
2352
  // state into an unretirable question during SessionEnd cleanup.
1623
- return { notes }
2353
+ return {
2354
+ notes,
2355
+ log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
2356
+ }
1624
2357
  }
1625
2358
  }
1626
2359
  if (orphans.length > 0) {
@@ -1630,8 +2363,24 @@ export function handleSessionEnd(
1630
2363
  `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
1631
2364
  )
1632
2365
  }
2366
+ if (state.accepted !== undefined) {
2367
+ const preserved: SessionState = { accepted: state.accepted }
2368
+ if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
2369
+ if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
2370
+ if (state.continuation !== undefined) preserved.continuation = state.continuation
2371
+ writeSessionState(sessionId, env, preserved)
2372
+ notes.push('preserved an accepted device answer for this exact session to resume')
2373
+ return {
2374
+ notes,
2375
+ log: {
2376
+ outcome: 'answer-preserved',
2377
+ queued_retirements: orphans.length,
2378
+ accepted_answers: state.accepted.answers.length,
2379
+ },
2380
+ }
2381
+ }
1633
2382
  clearSessionState(sessionId, env)
1634
- return { notes }
2383
+ return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
1635
2384
  }
1636
2385
 
1637
2386
  // ---------------------------------------------------------------------------
@@ -1676,6 +2425,7 @@ export function registerQuestion(
1676
2425
  pending: [
1677
2426
  ...pending,
1678
2427
  {
2428
+ question_id: `q_${randomBytes(12).toString('base64url')}`,
1679
2429
  asked_at: now,
1680
2430
  ...question,
1681
2431
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),