@raidiant/notifai 4.0.0 → 6.0.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 (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
package/src/hooks.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  writeFileSync,
12
12
  } from 'node:fs'
13
13
  import path from 'node:path'
14
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
14
15
  import type {
15
16
  LifecycleEndState,
16
17
  ListRepliesResponse,
@@ -37,6 +38,7 @@ import {
37
38
  import { buildDraft } from './send.js'
38
39
  import { atomicWriteFileSync } from './atomic-file.js'
39
40
  import { withFileLock } from './file-lock.js'
41
+ import { inferInvocationContext } from './invocation-context.js'
40
42
  import type { Logger } from './logging.js'
41
43
  import {
42
44
  HARNESS_CAPABILITIES,
@@ -127,11 +129,19 @@ export interface SessionState {
127
129
  * the service confirms the agent-authored follow-up exists.
128
130
  */
129
131
  acknowledgement_due?: AcknowledgementDue[]
132
+ /** Consecutive turns this session has been held for an acknowledgement. */
133
+ acknowledgement_blocks?: number
130
134
  }
131
135
 
132
136
  export interface AcknowledgementDue {
133
137
  request_id: string
134
138
  recorded_at: number
139
+ /**
140
+ * Whether this request's acknowledgement must carry text. Absent on state
141
+ * written before the obligation started recording it; treated as true, which
142
+ * is the default the service ships.
143
+ */
144
+ text_required?: boolean
135
145
  }
136
146
 
137
147
  interface AcceptedAnswerDelivery {
@@ -152,6 +162,8 @@ interface AcceptedAnswerDelivery {
152
162
  * passes through.
153
163
  */
154
164
  delivery_attempts?: number
165
+ /** Turns this answer has been held without being handed to the agent. */
166
+ held_deliveries?: number
155
167
  }
156
168
 
157
169
  /** A delivered question awaiting its retirement push. */
@@ -184,12 +196,15 @@ export interface OrphanRetirement extends RetiringQuestion {
184
196
  }
185
197
 
186
198
  /**
187
- * Past this, the question's reply window (3600s) has long expired server-side
188
- * and the companion shows it as dead on next open anyway; pushing a retirement
189
- * sync for it is noise. Also the backstop that keeps an unreachable server
190
- * from growing the queue for ever.
199
+ * A retirement is only worth syncing while the question it retires could still
200
+ * be answered, so this has to outlive the longest reply window a user can
201
+ * choose — it is derived from that bound rather than from a guess about how
202
+ * long questions stay open, which is exactly the assumption that broke when
203
+ * the window stopped being one hour. Past it the companion shows the question
204
+ * as dead on next open anyway. Also the backstop that keeps an unreachable
205
+ * server from growing the queue for ever.
191
206
  */
192
- const ORPHAN_TTL_MS = 24 * 3600 * 1000
207
+ const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000
193
208
 
194
209
  /** More orphans than this means something is looping; keep the newest. */
195
210
  const ORPHAN_QUEUE_CAP = 50
@@ -228,6 +243,22 @@ export interface PendingQuestion {
228
243
  submission?: PendingSubmissionIntent
229
244
  }
230
245
 
246
+ /** Keep immutable session grouping while replacing per-event Git location authoritatively. */
247
+ function sourceContextAtHookEvent(
248
+ source: SourceContextT | undefined,
249
+ cwd: string | undefined,
250
+ ): SourceContextT | undefined {
251
+ const invocation = cwd === undefined ? null : inferInvocationContext(cwd)
252
+ const current: SourceContextT = {
253
+ ...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
254
+ ...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
255
+ ...(source?.harness !== undefined ? { harness: source.harness } : {}),
256
+ ...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
257
+ ...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
258
+ }
259
+ return Object.keys(current).length === 0 ? undefined : current
260
+ }
261
+
231
262
  interface PendingSubmissionIntent {
232
263
  request_id: string
233
264
  idempotency_key: string
@@ -258,12 +289,35 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
258
289
  */
259
290
  export const WAITER_CEILING_SECONDS = 480
260
291
 
292
+ /**
293
+ * The same wall clock where the waiter runs in the background instead of
294
+ * inside a held turn.
295
+ *
296
+ * On a host that holds its turn, 480 s is a real cost to the person sitting in
297
+ * front of it, and the number above is right. On Claude Code the Stop hook
298
+ * returns at once and the waiter is detached, so the same 480 s buys nothing
299
+ * and costs a direct wake: an answer arriving at minute nine had to wait for
300
+ * the session's next turn even though nothing was blocked.
301
+ *
302
+ * This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
303
+ * because that declaration's kill is silent, and under the reply window the
304
+ * question was pushed with. Past it the journal still delivers at the next
305
+ * turn — the ceiling decides how long a *direct* wake stays possible, never
306
+ * whether the answer survives.
307
+ */
308
+ export const DETACHED_WAITER_CEILING_SECONDS = 3300
309
+
310
+ /** The waiter's wall clock, which depends on whether a turn is held open for it. */
311
+ export function waiterCeilingSeconds(detached: boolean): number {
312
+ return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS
313
+ }
314
+
261
315
  /**
262
316
  * The wire contract's shortest reply window. A question submitted with less
263
317
  * than this is rejected outright, and accepting one would in any case let the
264
318
  * server go on taking an answer after the waiter that owns it has returned.
265
319
  */
266
- const MIN_REPLY_WINDOW_SECONDS = 60
320
+ export const MIN_REPLY_WINDOW_SECONDS = 60
267
321
 
268
322
  /**
269
323
  * The terminal-first wait: the question sits in the terminal for
@@ -476,6 +530,17 @@ export function pruneAbandonedSessions(
476
530
  * no trustworthy PID fall back to an age limit.
477
531
  */
478
532
  const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
533
+
534
+ /**
535
+ * How long a crashed claim *guard* blocks the next hook.
536
+ *
537
+ * The claim itself may legitimately be held for the whole waiter, so it takes
538
+ * that ceiling. The guard is a short lock around the claim's own bookkeeping —
539
+ * a few file operations — and borrowing the waiter's ceiling for it meant a
540
+ * hook killed at exactly the wrong moment wedged this session's Stop path for
541
+ * eight minutes, for a critical section that never runs longer than a second.
542
+ */
543
+ const CLAIM_GUARD_TTL_MS = 30_000
479
544
  const heldClaims = new Map<string, string>()
480
545
 
481
546
  export function claimQuestionPush(
@@ -569,7 +634,7 @@ function acquireClaimGuard(file: string): boolean {
569
634
  } catch (err) {
570
635
  if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
571
636
  try {
572
- if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS) return false
637
+ if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS) return false
573
638
  } catch {
574
639
  // A vanished/corrupt guard is safe to retry once.
575
640
  }
@@ -595,6 +660,50 @@ interface StoredProjectSessionPointer extends ProjectSessionPointer {
595
660
  updatedAt: number
596
661
  }
597
662
 
663
+ interface StoredProjectSessionDocument {
664
+ root: Record<string, unknown>
665
+ sessions: unknown[]
666
+ }
667
+
668
+ export interface ProjectSessionPointer {
669
+ sessionId: string
670
+ harness: HookHarness
671
+ }
672
+
673
+ function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPointer | null {
674
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return null
675
+ const entry = candidate as {
676
+ session_id?: unknown
677
+ updated_at?: unknown
678
+ harness?: unknown
679
+ }
680
+ if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
681
+ if (typeof entry.updated_at !== 'number') return null
682
+ if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return null
683
+ return {
684
+ sessionId: entry.session_id,
685
+ updatedAt: entry.updated_at,
686
+ harness: entry.harness as HookHarness,
687
+ }
688
+ }
689
+
690
+ function readStoredProjectSessionDocument(file: string): StoredProjectSessionDocument {
691
+ if (!existsSync(file)) return { root: {}, sessions: [] }
692
+ try {
693
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
694
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
695
+ return { root: {}, sessions: [] }
696
+ }
697
+ const root = parsed as Record<string, unknown>
698
+ return {
699
+ root,
700
+ sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
701
+ }
702
+ } catch {
703
+ return { root: {}, sessions: [] }
704
+ }
705
+ }
706
+
598
707
  /** Records every live session working in a directory, for `notifai ask`. */
599
708
  export function writeProjectSession(
600
709
  cwd: string,
@@ -605,56 +714,37 @@ export function writeProjectSession(
605
714
  ): void {
606
715
  const file = projectSessionPointerPath(cwd, env)
607
716
  withFileLock(`${file}.lock`, () => {
608
- const existing = readStoredProjectSessionPointers(file).filter(
609
- (entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000,
610
- )
717
+ const stored = readStoredProjectSessionDocument(file)
718
+ const existing: unknown[] = []
719
+ let refreshed: Record<string, unknown> = {}
720
+ for (const candidate of stored.sessions) {
721
+ const entry = storedProjectSessionPointer(candidate)
722
+ if (entry?.sessionId === sessionId) {
723
+ refreshed = { ...refreshed, ...(candidate as Record<string, unknown>) }
724
+ } else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
725
+ existing.push(candidate)
726
+ }
727
+ }
611
728
  atomicWriteFileSync(
612
729
  file,
613
730
  `${JSON.stringify({
731
+ ...stored.root,
614
732
  sessions: [
615
- ...existing.map((entry) => ({
616
- session_id: entry.sessionId,
617
- updated_at: entry.updatedAt,
618
- harness: entry.harness,
619
- })),
620
- { session_id: sessionId, updated_at: now, harness },
733
+ ...existing,
734
+ { ...refreshed, session_id: sessionId, updated_at: now, harness },
621
735
  ],
622
736
  })}\n`,
623
737
  )
624
738
  })
625
739
  }
626
740
 
627
- export interface ProjectSessionPointer {
628
- sessionId: string
629
- harness: HookHarness
630
- }
631
-
632
741
  function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
633
- if (!existsSync(file)) return []
634
- try {
635
- const parsed = JSON.parse(readFileSync(file, 'utf8')) as { sessions?: unknown }
636
- if (!Array.isArray(parsed.sessions)) return []
637
- return parsed.sessions.flatMap((candidate): StoredProjectSessionPointer[] => {
638
- if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return []
639
- const entry = candidate as {
640
- session_id?: unknown
641
- updated_at?: unknown
642
- harness?: unknown
643
- }
644
- if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
645
- if (typeof entry.updated_at !== 'number') return []
646
- if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
647
- return [
648
- {
649
- sessionId: entry.session_id,
650
- updatedAt: entry.updated_at,
651
- harness: entry.harness as HookHarness,
652
- },
653
- ]
654
- })
655
- } catch {
656
- return []
657
- }
742
+ return readStoredProjectSessionDocument(file).sessions.flatMap(
743
+ (candidate): StoredProjectSessionPointer[] => {
744
+ const entry = storedProjectSessionPointer(candidate)
745
+ return entry === null ? [] : [entry]
746
+ },
747
+ )
658
748
  }
659
749
 
660
750
  /**
@@ -744,21 +834,21 @@ function clearMatchingProjectSession(
744
834
  ): void {
745
835
  const file = projectSessionPointerPath(cwd, env)
746
836
  withFileLock(`${file}.lock`, () => {
747
- const remaining = readStoredProjectSessionPointers(file).filter(
748
- (entry) => entry.sessionId !== sessionId,
749
- )
750
- if (remaining.length === 0) {
837
+ const stored = readStoredProjectSessionDocument(file)
838
+ const remaining = stored.sessions.filter((candidate) => {
839
+ const entry = storedProjectSessionPointer(candidate)
840
+ return entry === null || entry.sessionId !== sessionId
841
+ })
842
+ const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions')
843
+ if (remaining.length === 0 && !hasUnknownRootFields) {
751
844
  rmSync(file, { force: true })
752
845
  return
753
846
  }
754
847
  atomicWriteFileSync(
755
848
  file,
756
849
  `${JSON.stringify({
757
- sessions: remaining.map((entry) => ({
758
- session_id: entry.sessionId,
759
- updated_at: entry.updatedAt,
760
- harness: entry.harness,
761
- })),
850
+ ...stored.root,
851
+ sessions: remaining,
762
852
  })}\n`,
763
853
  )
764
854
  })
@@ -795,19 +885,29 @@ export interface HookContext {
795
885
  * the user sees in the terminal — but it is free to be rewritten, and a log a
796
886
  * filter cannot rely on is a log nobody filters.
797
887
  */
798
- export type GateReason =
799
- | 'no-session'
800
- | 'no-question'
801
- | 'answered'
802
- | 'already-asked'
803
- | 'continuation-repeat'
804
- | 'continuation-limit'
805
- | 'delivery-limit'
806
- | 'acknowledgement-required'
807
- | 'notifications-off'
808
- | 'claimed-elsewhere'
809
- | 'no-devices'
810
- | 'proceeding'
888
+ export const GATE_REASONS = [
889
+ 'no-session',
890
+ 'no-question',
891
+ 'answered',
892
+ 'continuation-repeat',
893
+ 'continuation-limit',
894
+ 'delivery-limit',
895
+ 'acknowledgement-required',
896
+ 'acknowledgement-abandoned',
897
+ 'harness-cannot-continue',
898
+ 'notifications-off',
899
+ 'claimed-elsewhere',
900
+ 'elapsed',
901
+ 'proceeding',
902
+ ] as const
903
+
904
+ /**
905
+ * A runtime list rather than a bare type, so the guidance that tells agents to
906
+ * filter on these can be tested against the set the code can actually emit.
907
+ * `already-asked` and `no-devices` were declared here for a long time and never
908
+ * emitted once; documenting them taught agents to filter for silence.
909
+ */
910
+ export type GateReason = (typeof GATE_REASONS)[number]
811
911
 
812
912
  function gate(
813
913
  ctx: HookContext,
@@ -875,7 +975,7 @@ async function prepareQuestionSubmission(
875
975
  ...(options.media !== undefined ? { media: options.media } : {}),
876
976
  device: answerable,
877
977
  reply: true,
878
- replyWindow: Math.max(60, options.windowSeconds),
978
+ replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
879
979
  questions: options.questions,
880
980
  collapseKey,
881
981
  },
@@ -1001,7 +1101,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
1001
1101
  (device) =>
1002
1102
  (device.platform === 'ios' || device.platform === 'macos') &&
1003
1103
  device.registration_healthy &&
1004
- device.reply_protocol_version === 2,
1104
+ device.capabilities?.includes('answer') === true &&
1105
+ device.derived_status !== 'must_update',
1005
1106
  )
1006
1107
  .filter((device) => configured === null || configured.includes(device.device_id))
1007
1108
  .map((device) => device.device_id)
@@ -1091,7 +1192,6 @@ const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
1091
1192
  answered: 'Answered',
1092
1193
  answered_elsewhere: 'Answered in the terminal',
1093
1194
  expired: 'Question expired',
1094
- superseded: 'Replaced by a newer question',
1095
1195
  }
1096
1196
 
1097
1197
  /**
@@ -1134,6 +1234,7 @@ export function parkForRetirement(
1134
1234
  function retiringQuestion(
1135
1235
  pending: PendingQuestion,
1136
1236
  state: LifecycleEndState,
1237
+ cwd?: string,
1137
1238
  ): RetiringQuestion | null {
1138
1239
  const hasRequest = pending.request_id !== undefined
1139
1240
  const hasCollapse = pending.collapse_key !== undefined
@@ -1144,13 +1245,14 @@ function retiringQuestion(
1144
1245
  'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
1145
1246
  )
1146
1247
  }
1248
+ const source = sourceContextAtHookEvent(pending.source, cwd)
1147
1249
  return {
1148
1250
  request_id: pending.request_id!,
1149
1251
  collapse_key: pending.collapse_key!,
1150
1252
  device_ids: [...pending.device_ids!],
1151
1253
  question: pending.question,
1152
1254
  ...(pending.project !== undefined ? { project: pending.project } : {}),
1153
- ...(pending.source !== undefined ? { source: pending.source } : {}),
1255
+ ...(source !== undefined ? { source } : {}),
1154
1256
  state,
1155
1257
  }
1156
1258
  }
@@ -1312,6 +1414,21 @@ const LATE_PROMPT_POLL_SECONDS = 3
1312
1414
  */
1313
1415
  export const MAX_CONTINUATION_COUNT = 3
1314
1416
 
1417
+ /**
1418
+ * How many turns an answer may be *held* before it is settled unread.
1419
+ *
1420
+ * A hold is not a delivery: the route reported that it handed nothing over, so
1421
+ * the agent has not seen the answer and the wake-loop the continuation cap
1422
+ * exists to stop has not happened. Counting holds against that cap meant three
1423
+ * turns where a liveness probe could not reach a busy session were enough to
1424
+ * discard an answer the user had already given.
1425
+ *
1426
+ * It still has to end. A route that can never deliver would otherwise retry
1427
+ * every turn for the life of the session, so holds get their own, far looser
1428
+ * bound — loose because each one costs nothing and the answer is still wanted.
1429
+ */
1430
+ export const MAX_HELD_DELIVERIES = 20
1431
+
1315
1432
  interface PendingPoll {
1316
1433
  /** The answer to act on: the latest reply, because a later one corrects. */
1317
1434
  reply: ReplyView | null
@@ -1352,6 +1469,8 @@ interface AnsweredPending {
1352
1469
  replies: ReplyView[]
1353
1470
  /** Immutable server snapshot for this request, known after a replies/close response. */
1354
1471
  agent_acknowledgement_required?: boolean | undefined
1472
+ /** Whether that acknowledgement must carry text; the account's snapshot. */
1473
+ agent_acknowledgement_text_required?: boolean | undefined
1355
1474
  }
1356
1475
 
1357
1476
  interface FinalizedPending {
@@ -1384,6 +1503,8 @@ function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1384
1503
  replies,
1385
1504
  agent_acknowledgement_required:
1386
1505
  finalized.response?.agent_acknowledgement_required,
1506
+ agent_acknowledgement_text_required:
1507
+ finalized.response?.agent_acknowledgement_text_required,
1387
1508
  }
1388
1509
  }
1389
1510
 
@@ -1449,6 +1570,46 @@ function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1449
1570
  return a.question === b.question && a.asked_at === b.asked_at
1450
1571
  }
1451
1572
 
1573
+ /**
1574
+ * The frozen draft itself will never be accepted. Replaying it forever hides a
1575
+ * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
1576
+ * Auth, conflicts, and timeouts are not this class.
1577
+ */
1578
+ function isTerminalDraftRejection(err: ApiCallError): boolean {
1579
+ return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
1580
+ }
1581
+
1582
+ function dropPendingQuestion(
1583
+ sessionId: string,
1584
+ env: NodeJS.ProcessEnv,
1585
+ entry: PendingQuestion,
1586
+ ): void {
1587
+ updateSessionState(sessionId, env, (current) => {
1588
+ const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
1589
+ const next: SessionState = { ...current }
1590
+ if (pending.length > 0) next.pending = pending
1591
+ else delete next.pending
1592
+ return next
1593
+ })
1594
+ }
1595
+
1596
+ function clearFrozenSubmission(
1597
+ sessionId: string,
1598
+ env: NodeJS.ProcessEnv,
1599
+ entry: PendingQuestion,
1600
+ ): void {
1601
+ updateSessionState(sessionId, env, (current) => {
1602
+ const list = pendingList(current)
1603
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
1604
+ if (index < 0) return current
1605
+ const next = [...list]
1606
+ const copy = { ...next[index]! }
1607
+ delete copy.submission
1608
+ next[index] = copy
1609
+ return { ...current, pending: next }
1610
+ })
1611
+ }
1612
+
1452
1613
  /** The question set this pending record pushes, however it was registered. */
1453
1614
  function pendingQuestions(pending: PendingQuestion): QuestionT[] {
1454
1615
  return pending.questions ?? [{ id: 'q1', text: pending.question }]
@@ -1496,8 +1657,20 @@ function answerContext(answered: AnsweredPending): string {
1496
1657
  * tied to the question that asked it, with a truthful note about anything
1497
1658
  * still waiting.
1498
1659
  */
1499
- function acknowledgementCommand(requestId: string): string {
1500
- return `notifai acknowledge ${requestId} --text <text>`
1660
+ function acknowledgementCommand(requestId: string, textRequired = true): string {
1661
+ return textRequired
1662
+ ? `notifai acknowledge ${requestId} --text <text>`
1663
+ : `notifai acknowledge ${requestId}`
1664
+ }
1665
+
1666
+ /**
1667
+ * The acknowledgement is owed either way; only its text is conditional. So the
1668
+ * instruction never says "you may skip this" — it says what to run.
1669
+ */
1670
+ function acknowledgementDemand(textRequired: boolean): string {
1671
+ return textRequired
1672
+ ? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
1673
+ : ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words'
1501
1674
  }
1502
1675
 
1503
1676
  function acknowledgementContext(answered: AnsweredPending[]): string {
@@ -1509,20 +1682,23 @@ function acknowledgementContext(answered: AnsweredPending[]): string {
1509
1682
  return ' Agent Acknowledgement is not required for the answered request(s).'
1510
1683
  }
1511
1684
  if (due.length === 1) {
1512
- const requestId = due[0]!.pending.request_id!
1685
+ const entry = due[0]!
1686
+ const requestId = entry.pending.request_id!
1687
+ const textRequired = entry.agent_acknowledgement_text_required !== false
1513
1688
  return (
1514
1689
  ` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
1515
- `\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`
1690
+ `\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`
1516
1691
  )
1517
1692
  }
1693
+ const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false)
1518
1694
  const commands = due
1519
1695
  .map((entry) => {
1520
1696
  const requestId = entry.pending.request_id!
1521
- return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
1697
+ return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``
1522
1698
  })
1523
1699
  .join('\n')
1524
1700
  return (
1525
- ` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below with non-empty text saying what concrete work you will do because of that reply; a bare acknowledgement is insufficient:\n` +
1701
+ ` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below${acknowledgementDemand(anyTextRequired)}:\n` +
1526
1702
  commands
1527
1703
  )
1528
1704
  }
@@ -1585,7 +1761,11 @@ function stageAcceptedAnswers(
1585
1761
  requestId !== undefined &&
1586
1762
  !acknowledgementDue.some((entry) => entry.request_id === requestId)
1587
1763
  ) {
1588
- acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at })
1764
+ acknowledgementDue.push({
1765
+ request_id: requestId,
1766
+ recorded_at: accepted.recorded_at,
1767
+ text_required: answer.agent_acknowledgement_text_required !== false,
1768
+ })
1589
1769
  }
1590
1770
  }
1591
1771
  const next: SessionState = {
@@ -1633,14 +1813,83 @@ export function clearAcknowledgementObligation(
1633
1813
 
1634
1814
  function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
1635
1815
  const commands = due
1636
- .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
1816
+ .map(
1817
+ (entry) =>
1818
+ `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``,
1819
+ )
1637
1820
  .join('\n')
1821
+ const anyTextRequired = due.some((entry) => entry.text_required !== false)
1638
1822
  return (
1639
1823
  `Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
1640
- `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'} with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient:\n${commands}`
1824
+ `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`
1641
1825
  )
1642
1826
  }
1643
1827
 
1828
+ /**
1829
+ * How many turns in a row a session may be held waiting for an acknowledgement.
1830
+ *
1831
+ * The gate is the one place hooks deliberately break the fail-open rule, and it
1832
+ * had no bound at all: an agent that could not or would not acknowledge — or a
1833
+ * server that stayed unreachable, since an error here counts as unresolved —
1834
+ * held every turn of that session for ever. Blocking a user's agent
1835
+ * indefinitely does not get them an acknowledgement; it costs them the agent as
1836
+ * well as the acknowledgement.
1837
+ */
1838
+ const MAX_ACKNOWLEDGEMENT_BLOCKS = 3
1839
+
1840
+ /**
1841
+ * Hold the turn for an outstanding acknowledgement, or give up and let it
1842
+ * through once holding has stopped being worth its cost.
1843
+ *
1844
+ * Returns the outcome that blocks the turn, or `null` to carry on.
1845
+ */
1846
+ function holdForAcknowledgement(
1847
+ ctx: HookContext,
1848
+ sessionId: string,
1849
+ due: readonly AcknowledgementDue[],
1850
+ notes: string[],
1851
+ ): HookOutcome | null {
1852
+ if (due.length === 0) return null
1853
+ const requestIds = due.map((entry) => entry.request_id)
1854
+ const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1
1855
+ if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
1856
+ // Drop the obligation with the reason recorded. The answer was already
1857
+ // delivered; what is lost is the agent's receipt for it, and the log is
1858
+ // where that loss stays visible.
1859
+ for (const requestId of requestIds) clearAcknowledgementObligation(sessionId, ctx.env, requestId)
1860
+ resetAcknowledgementBlocks(sessionId, ctx.env)
1861
+ gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
1862
+ request_ids: requestIds,
1863
+ blocks: blocks - 1,
1864
+ limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
1865
+ })
1866
+ notes.push(
1867
+ `no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`,
1868
+ )
1869
+ return null
1870
+ }
1871
+ updateSessionState(sessionId, ctx.env, (current) => ({
1872
+ ...current,
1873
+ acknowledgement_blocks: blocks,
1874
+ }))
1875
+ gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks })
1876
+ return {
1877
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1878
+ notes,
1879
+ log: { stage: 'acknowledgement-required', request_ids: requestIds },
1880
+ }
1881
+ }
1882
+
1883
+ /** A turn that was not held resets the streak; only consecutive holds count. */
1884
+ function resetAcknowledgementBlocks(sessionId: string, env: NodeJS.ProcessEnv): void {
1885
+ updateSessionState(sessionId, env, (current) => {
1886
+ if (current.acknowledgement_blocks === undefined) return current
1887
+ const next = { ...current }
1888
+ delete next.acknowledgement_blocks
1889
+ return next
1890
+ })
1891
+ }
1892
+
1644
1893
  async function reconcileAcknowledgementObligations(
1645
1894
  ctx: HookContext,
1646
1895
  sessionId: string,
@@ -1686,9 +1935,10 @@ function settleAcceptedAnswers(
1686
1935
  ctx: HookContext,
1687
1936
  sessionId: string,
1688
1937
  accepted: AcceptedAnswerDelivery,
1938
+ cwd?: string,
1689
1939
  ): void {
1690
1940
  const retirements = accepted.answers
1691
- .map(({ pending }) => retiringQuestion(pending, 'answered'))
1941
+ .map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
1692
1942
  .filter((entry): entry is RetiringQuestion => entry !== null)
1693
1943
  updateSessionState(sessionId, ctx.env, (current) => {
1694
1944
  const retiring = [...(current.retiring ?? [])]
@@ -1717,7 +1967,7 @@ async function retirePendings(
1717
1967
  updateSessionState(sessionId, ctx.env, (current) => {
1718
1968
  const retiring = [...(current.retiring ?? [])]
1719
1969
  for (const entry of entries) {
1720
- const retirement = retiringQuestion(entry, state)
1970
+ const retirement = retiringQuestion(entry, state, envelope.cwd)
1721
1971
  if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1722
1972
  retiring.push(retirement)
1723
1973
  }
@@ -1800,6 +2050,9 @@ export async function handleUserPromptSubmit(
1800
2050
  agent_acknowledgement_required:
1801
2051
  response?.agent_acknowledgement_required ??
1802
2052
  entry.agent_acknowledgement_required,
2053
+ agent_acknowledgement_text_required:
2054
+ response?.agent_acknowledgement_text_required ??
2055
+ entry.agent_acknowledgement_text_required,
1803
2056
  }
1804
2057
  })
1805
2058
  if (answered.length > 0) {
@@ -1824,7 +2077,7 @@ export async function handleUserPromptSubmit(
1824
2077
  const retiring = [...(current.retiring ?? [])]
1825
2078
  const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
1826
2079
  for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
1827
- const retirement = retiringQuestion(entry, 'answered_elsewhere')
2080
+ const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
1828
2081
  if (
1829
2082
  retirement !== null &&
1830
2083
  !retiring.some((parked) => parked.request_id === retirement.request_id)
@@ -1832,21 +2085,24 @@ export async function handleUserPromptSubmit(
1832
2085
  retiring.push(retirement)
1833
2086
  }
1834
2087
  }
1835
- return {
1836
- last_prompt_at: ctx.now(),
1837
- ...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
1838
- ...(unasked.length > 0 ? { pending: unasked } : {}),
1839
- ...(retiring.length > 0 ? { retiring } : {}),
1840
- ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1841
- // The user typed, so whatever chain of answer-driven continuations was
1842
- // running, a human has taken the turn and the consecutive count starts
1843
- // over. Only a real prompt reaches here: the journal branch above returns
1844
- // first, and a wake route's injected message always arrives while its own
1845
- // answer is still journaled, so it can never pass for the user's presence.
1846
- ...(current.continuation === undefined
1847
- ? {}
1848
- : { continuation: { ...current.continuation, count: 0 } }),
2088
+
2089
+ // Start from the whole document so fields introduced by a newer CLI remain
2090
+ // attached while this older writer applies the prompt transition it knows.
2091
+ const next: SessionState = { ...current, last_prompt_at: ctx.now() }
2092
+ if (unasked.length > 0) next.pending = unasked
2093
+ else delete next.pending
2094
+ if (retiring.length > 0) next.retiring = retiring
2095
+ else delete next.retiring
2096
+
2097
+ // The user typed, so whatever chain of answer-driven continuations was
2098
+ // running, a human has taken the turn and the consecutive count starts
2099
+ // over. Only a real prompt reaches here: the journal branch above returns
2100
+ // first, and a wake route's injected message always arrives while its own
2101
+ // answer is still journaled, so it can never pass for the user's presence.
2102
+ if (current.continuation !== undefined) {
2103
+ next.continuation = { ...current.continuation, count: 0 }
1849
2104
  }
2105
+ return next
1850
2106
  })
1851
2107
  // The bridge that lets a plain `notifai ask` find the hook's canonical
1852
2108
  // session: an agent shell command gets no hook payload, and not every
@@ -1970,9 +2226,16 @@ export async function runEscalationWaiter(
1970
2226
  accepted.delivered_at !== undefined || envelope.stop_hook_active === true
1971
2227
  if (!deliveryProven) {
1972
2228
  for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
1973
- return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes)
2229
+ return await deliverAcceptedAnswers(
2230
+ ctx,
2231
+ sessionId,
2232
+ options.route,
2233
+ accepted,
2234
+ notes,
2235
+ envelope.cwd,
2236
+ )
1974
2237
  }
1975
- settleAcceptedAnswers(ctx, sessionId, accepted)
2238
+ settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd)
1976
2239
  settledAnswerThisPass = true
1977
2240
  state = readSessionState(sessionId, ctx.env)
1978
2241
  const currentAcceptedIds = new Set(
@@ -1987,19 +2250,8 @@ export async function runEscalationWaiter(
1987
2250
  currentAcceptedIds.has(entry.request_id),
1988
2251
  ),
1989
2252
  )
1990
- if (due.length > 0) {
1991
- gate(ctx, 'held', 'acknowledgement-required', {
1992
- request_ids: due.map((entry) => entry.request_id),
1993
- })
1994
- return {
1995
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1996
- notes,
1997
- log: {
1998
- stage: 'acknowledgement-required',
1999
- request_ids: due.map((entry) => entry.request_id),
2000
- },
2001
- }
2002
- }
2253
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes)
2254
+ if (held !== null) return held
2003
2255
  state = readSessionState(sessionId, ctx.env)
2004
2256
  }
2005
2257
 
@@ -2009,19 +2261,9 @@ export async function runEscalationWaiter(
2009
2261
  sessionId,
2010
2262
  state.acknowledgement_due!,
2011
2263
  )
2012
- if (due.length > 0) {
2013
- gate(ctx, 'held', 'acknowledgement-required', {
2014
- request_ids: due.map((entry) => entry.request_id),
2015
- })
2016
- return {
2017
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
2018
- notes,
2019
- log: {
2020
- stage: 'acknowledgement-required',
2021
- request_ids: due.map((entry) => entry.request_id),
2022
- },
2023
- }
2024
- }
2264
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes)
2265
+ if (held !== null) return held
2266
+ resetAcknowledgementBlocks(sessionId, ctx.env)
2025
2267
  state = readSessionState(sessionId, ctx.env)
2026
2268
  }
2027
2269
 
@@ -2089,9 +2331,24 @@ async function deliverAcceptedAnswers(
2089
2331
  route: EscalationDeliveryRoute,
2090
2332
  accepted: AcceptedAnswerDelivery,
2091
2333
  notes: string[],
2334
+ cwd?: string,
2092
2335
  ): Promise<HookOutcome> {
2093
2336
  const { answers: answered, remaining } = accepted
2094
2337
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
2338
+ const held = accepted.held_deliveries ?? 0
2339
+ if (held >= MAX_HELD_DELIVERIES) {
2340
+ gate(ctx, 'held', 'delivery-limit', {
2341
+ route: route.kind,
2342
+ held,
2343
+ limit: MAX_HELD_DELIVERIES,
2344
+ request_ids: requestIds,
2345
+ })
2346
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
2347
+ notes.push(
2348
+ `no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
2349
+ )
2350
+ return { notes }
2351
+ }
2095
2352
  const attempt = (accepted.delivery_attempts ?? 0) + 1
2096
2353
  if (attempt > MAX_CONTINUATION_COUNT) {
2097
2354
  gate(ctx, 'held', 'delivery-limit', {
@@ -2103,7 +2360,7 @@ async function deliverAcceptedAnswers(
2103
2360
  // Settle rather than journal for ever. The answer has been offered as often
2104
2361
  // as any working route could need, it is already in the notes of each of
2105
2362
  // those turns, and the questions it answered still deserve retirement.
2106
- settleAcceptedAnswers(ctx, sessionId, accepted)
2363
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
2107
2364
  notes.push(
2108
2365
  `the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
2109
2366
  )
@@ -2142,6 +2399,17 @@ async function deliverAcceptedAnswers(
2142
2399
  delivered_at: ctx.now(),
2143
2400
  delivered_route: deliveredRoute,
2144
2401
  }))
2402
+ } else if (delivered.acknowledgement === 'held') {
2403
+ // Give the delivery attempt back and count the hold instead. The attempt
2404
+ // was counted before the call because one that dies mid-delivery still
2405
+ // happened and a counter written afterwards could be evaded by crashing —
2406
+ // but a route that *returns* `held` proves both that this process survived
2407
+ // and that nothing was handed over.
2408
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
2409
+ ...current,
2410
+ delivery_attempts: accepted.delivery_attempts ?? 0,
2411
+ held_deliveries: (current.held_deliveries ?? 0) + 1,
2412
+ }))
2145
2413
  }
2146
2414
  ctx.log?.info('hook.answer', {
2147
2415
  answered: true,
@@ -2182,7 +2450,11 @@ async function handleClaimedStop(
2182
2450
  ) {
2183
2451
  for (const entry of live) await closeQuietly(ctx, entry.request_id!)
2184
2452
  await retirePendings(ctx, envelope, sessionId, pending, 'expired')
2185
- gate(ctx, 'held', 'no-question')
2453
+ // Not `no-question`: there was a question, and this says so. Reading the
2454
+ // log to find out why an ask never travelled is the whole reason these
2455
+ // reasons exist, and the wrong one sent that reader looking for a question
2456
+ // that was right there.
2457
+ gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness })
2186
2458
  notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
2187
2459
  return { notes }
2188
2460
  }
@@ -2224,6 +2496,9 @@ async function handleClaimedStop(
2224
2496
  agent_acknowledgement_required:
2225
2497
  response?.agent_acknowledgement_required ??
2226
2498
  entry.agent_acknowledgement_required,
2499
+ agent_acknowledgement_text_required:
2500
+ response?.agent_acknowledgement_text_required ??
2501
+ entry.agent_acknowledgement_text_required,
2227
2502
  }
2228
2503
  })
2229
2504
  const accepted = stageAcceptedAnswers(
@@ -2235,7 +2510,7 @@ async function handleClaimedStop(
2235
2510
  for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
2236
2511
  // Anything still unasked rides the next Stop: the agent is being resumed
2237
2512
  // with answers right now, and may not even need the rest afterwards.
2238
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2513
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2239
2514
  }
2240
2515
  const recoverableLive = live.filter(
2241
2516
  (entry) => !permanentFailures.has(entry.request_id!),
@@ -2352,7 +2627,7 @@ async function escalate(
2352
2627
  await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
2353
2628
  ctx.log?.debug('hook.gate', {
2354
2629
  verdict: 'grace',
2355
- reason: 'elapsed',
2630
+ reason: 'elapsed' satisfies GateReason,
2356
2631
  waited_from: oldest,
2357
2632
  grace_seconds: ctx.config.ask_grace_seconds.value,
2358
2633
  })
@@ -2361,18 +2636,28 @@ async function escalate(
2361
2636
  // its own notification — one ask never stands in for another.
2362
2637
  const submitted: PendingQuestion[] = []
2363
2638
  for (const entry of unasked) {
2364
- // The reply window is the question's own: whatever is left of the waiter's
2365
- // ceiling, granted to the server so it stops accepting answers at the same
2366
- // moment this owner stops listening for them.
2367
- const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000)
2368
- if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
2639
+ // How long the answer is accepted, and how long this owner listens for it,
2640
+ // are two different spans. They used to be one: the window was whatever
2641
+ // remained of the waiter's ceiling, so a question asked at a turn's end
2642
+ // stopped being answerable about eight minutes later, whatever the user
2643
+ // had configured.
2644
+ //
2645
+ // That was right when the waiter was the only way an answer came back. It
2646
+ // is not any more: a late answer is found by the next turn's poll or
2647
+ // replayed from the journal, so this owner giving up listening says
2648
+ // nothing about whether the user's answer is still wanted.
2649
+ const replyWindowSeconds = ctx.config.reply_window_seconds.value
2650
+ if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
2369
2651
  notes.push(
2370
- 'too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal',
2652
+ 'too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal',
2371
2653
  )
2372
2654
  continue
2373
2655
  }
2656
+ // The owner still stops listening at its own ceiling. Only the span the
2657
+ // server accepts answers into changed.
2374
2658
  const ownerDeadlineAt = ceilingAt
2375
2659
  const questions = pendingQuestions(entry)
2660
+ const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
2376
2661
  let intent = entry.submission
2377
2662
  if (intent === undefined) {
2378
2663
  if (entry.body === undefined) {
@@ -2386,7 +2671,7 @@ async function escalate(
2386
2671
  questions,
2387
2672
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2388
2673
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2389
- ...(entry.source !== undefined ? { source: entry.source } : {}),
2674
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
2390
2675
  event: 'agent_question',
2391
2676
  windowSeconds: replyWindowSeconds,
2392
2677
  ownerDeadlineAt,
@@ -2439,12 +2724,80 @@ async function escalate(
2439
2724
  try {
2440
2725
  receipt = await submitQuestion(ctx, intent)
2441
2726
  admissionConfirmed = true
2442
- } catch (err) {
2443
- if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
2444
- // A concrete client response is an admission rejection, not an
2445
- // ambiguous commit. Keep the frozen intent for a later retry and leave
2446
- // the question in the terminal; polling a request the server says it
2447
- // did not accept only burns the owner window and hides the real fault.
2727
+ } catch (caught) {
2728
+ let err: unknown = caught
2729
+ if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
2730
+ ctx.log?.error('hook.pushed', {
2731
+ ok: false,
2732
+ request_id: intent.request_id,
2733
+ status: err.status,
2734
+ code: err.code,
2735
+ message: err.message,
2736
+ })
2737
+ if (entry.submission !== undefined) {
2738
+ notes.push(
2739
+ `question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`,
2740
+ )
2741
+ clearFrozenSubmission(sessionId, ctx.env, entry)
2742
+ if (entry.body === undefined) {
2743
+ dropPendingQuestion(sessionId, ctx.env, entry)
2744
+ notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it')
2745
+ continue
2746
+ }
2747
+ const reminted = await prepareQuestionSubmission(ctx, {
2748
+ title: questions[0]!.text,
2749
+ body: entry.body,
2750
+ questions,
2751
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
2752
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
2753
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
2754
+ event: 'agent_question',
2755
+ windowSeconds: replyWindowSeconds,
2756
+ ownerDeadlineAt,
2757
+ })
2758
+ if ('error' in reminted) {
2759
+ dropPendingQuestion(sessionId, ctx.env, entry)
2760
+ notes.push(reminted.error)
2761
+ continue
2762
+ }
2763
+ intent = reminted
2764
+ updateSessionState(sessionId, ctx.env, (current) => {
2765
+ const list = pendingList(current)
2766
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
2767
+ if (index < 0) return current
2768
+ const next = [...list]
2769
+ next[index] = { ...next[index]!, submission: reminted }
2770
+ return { ...current, pending: next }
2771
+ })
2772
+ try {
2773
+ receipt = await submitQuestion(ctx, intent)
2774
+ admissionConfirmed = true
2775
+ } catch (retryErr) {
2776
+ if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
2777
+ dropPendingQuestion(sessionId, ctx.env, entry)
2778
+ notes.push(
2779
+ `reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`,
2780
+ )
2781
+ ctx.log?.error('hook.pushed', {
2782
+ ok: false,
2783
+ request_id: intent.request_id,
2784
+ status: retryErr.status,
2785
+ code: retryErr.code,
2786
+ message: retryErr.message,
2787
+ })
2788
+ continue
2789
+ }
2790
+ err = retryErr
2791
+ }
2792
+ } else {
2793
+ dropPendingQuestion(sessionId, ctx.env, entry)
2794
+ notes.push(
2795
+ `question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`,
2796
+ )
2797
+ continue
2798
+ }
2799
+ }
2800
+ if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
2448
2801
  notes.push(
2449
2802
  `question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
2450
2803
  )
@@ -2457,11 +2810,10 @@ async function escalate(
2457
2810
  })
2458
2811
  continue
2459
2812
  }
2460
- // This is deliberately not a fail-open return. A transport failure is
2461
- // ambiguous: the server may already have dispatched the card. The
2462
- // client-generated request id keeps polling/finalization possible.
2463
- notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
2464
- ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
2813
+ if (!admissionConfirmed) {
2814
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
2815
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
2816
+ }
2465
2817
  }
2466
2818
  if (admissionConfirmed) {
2467
2819
  ctx.log?.info('hook.pushed', {
@@ -2478,20 +2830,27 @@ async function escalate(
2478
2830
  : Date.parse(receipt.reply_expires_at)
2479
2831
  const live: PendingQuestion = {
2480
2832
  ...entry,
2833
+ ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
2481
2834
  request_id: intent.request_id,
2482
2835
  collapse_key: intent.collapse_key,
2483
2836
  device_ids: intent.device_ids,
2484
- // Submission and every later poll share one wall-clock owner. The
2485
- // server may start its relative window a little later while the submit
2486
- // response is in flight; closing at this deadline is therefore the
2487
- // authoritative backstop against accepting an answer into a void.
2488
- reply_deadline_at: Math.min(
2489
- intent.owner_deadline_at,
2490
- Number.isFinite(committedReplyDeadline)
2491
- ? committedReplyDeadline
2492
- : intent.owner_deadline_at,
2493
- ),
2837
+ // When the server stops accepting an answer — not when this owner stops
2838
+ // waiting for one. Those were the same number until the window became a
2839
+ // setting, and taking the minimum silently capped every question at the
2840
+ // waiter's ceiling: a question asked with a day-long window was swept as
2841
+ // stale about eight minutes later and retired, so the answer given over
2842
+ // lunch met a closed question.
2843
+ //
2844
+ // Both consumers of this field already clamp to the owner's own ceiling
2845
+ // (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
2846
+ // `Math.min(ceilingAt, …)` when deriving the wait), so recording the
2847
+ // real expiry here lengthens what the user gets without lengthening what
2848
+ // this process waits.
2849
+ reply_deadline_at: Number.isFinite(committedReplyDeadline)
2850
+ ? committedReplyDeadline
2851
+ : intent.owner_deadline_at,
2494
2852
  }
2853
+ if (intent.draft.source === undefined) delete live.source
2495
2854
  delete live.submission
2496
2855
  submitted.push(live)
2497
2856
  // Record what is now live on the user's devices BEFORE any wait. If we
@@ -2513,7 +2872,7 @@ async function escalate(
2513
2872
  // The entry vanished while the submit was in flight (the user's prompt
2514
2873
  // wiped the queue). The delivered notification must still be retirable,
2515
2874
  // so park it rather than lose its only identifiers.
2516
- const retirement = retiringQuestion(live, 'answered_elsewhere')!
2875
+ const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd)!
2517
2876
  const retiring = [...(current.retiring ?? [])]
2518
2877
  if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
2519
2878
  retiring.push(retirement)
@@ -2553,7 +2912,7 @@ async function escalate(
2553
2912
  )
2554
2913
  const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
2555
2914
  for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
2556
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2915
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2557
2916
  }
2558
2917
  const waitingOn = [
2559
2918
  ...alreadyLive.filter(
@@ -2578,10 +2937,37 @@ async function escalate(
2578
2937
  if (permanentFailure !== null) notes.push(permanentFailure)
2579
2938
 
2580
2939
  if (waited.byRequest.size === 0) {
2581
- // Once this owner returns, no shipped command-hook harness can guarantee
2582
- // a later answer reaches the same agent turn. Close first, then retire the
2583
- // local records, so the Companion never accepts an answer into a void.
2584
- const finalized = await finalizePendings(ctx, waitingOn)
2940
+ // This owner is done listening. That used to mean the question was over:
2941
+ // it closed the window server-side and retired the record, on the reasoning
2942
+ // that no later answer could reach the same agent turn.
2943
+ //
2944
+ // The reaching was never the point — the *accepting* is. A question whose
2945
+ // window is still open is polled again by the next prompt and the next
2946
+ // Stop, and an answer found there is journaled and replayed. Closing here
2947
+ // capped every question at this owner's ceiling however long a window the
2948
+ // user had configured, which is what made a day-long window mean eight
2949
+ // minutes.
2950
+ //
2951
+ // So: finalize only what the server has genuinely stopped accepting, and
2952
+ // leave the rest live for the turn that comes next. A session that truly
2953
+ // ends still retires its questions — that is what the session-end hook is
2954
+ // for, and it is the honest place for it.
2955
+ const expired = waitingOn.filter(
2956
+ (entry) =>
2957
+ entry.reply_deadline_at === undefined ||
2958
+ entry.reply_deadline_at <= ctx.now() ||
2959
+ // A permanent rejection is the server saying this question can never be
2960
+ // answered, whatever its window says. Leaving it open would strand a
2961
+ // dead question on the user's devices.
2962
+ waited.permanentFailures.has(entry.request_id!),
2963
+ )
2964
+ const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry))
2965
+ if (stillAnswerable.length > 0) {
2966
+ notes.push(
2967
+ `${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`,
2968
+ )
2969
+ }
2970
+ const finalized = await finalizePendings(ctx, expired)
2585
2971
  const finalAnswers = finalized
2586
2972
  .map(finalizedAnswer)
2587
2973
  .filter((entry): entry is AnsweredPending => entry !== null)
@@ -2604,7 +2990,7 @@ async function escalate(
2604
2990
  )
2605
2991
  const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
2606
2992
  for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
2607
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2993
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2608
2994
  }
2609
2995
  const requestIdSummary = summarizeRequestIds(waitingOn)
2610
2996
  ctx.log?.info('hook.answer', {
@@ -2643,6 +3029,8 @@ async function escalate(
2643
3029
  replies: authoritative,
2644
3030
  agent_acknowledgement_required:
2645
3031
  finalized.response?.agent_acknowledgement_required,
3032
+ agent_acknowledgement_text_required:
3033
+ finalized.response?.agent_acknowledgement_text_required,
2646
3034
  })
2647
3035
  if (finalized.response === null) {
2648
3036
  notes.push(
@@ -2657,7 +3045,7 @@ async function escalate(
2657
3045
  waitingOn.length - answered.length,
2658
3046
  )
2659
3047
  for (const answer of answered) reportAnswer(ctx, notes, answer, false)
2660
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
3048
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2661
3049
  }
2662
3050
 
2663
3051
  // ---------------------------------------------------------------------------
@@ -2686,7 +3074,7 @@ export function handleSessionEnd(
2686
3074
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
2687
3075
  for (const entry of pendingList(state)) {
2688
3076
  try {
2689
- const orphan = retiringQuestion(entry, 'expired')
3077
+ const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
2690
3078
  if (orphan !== null) orphans.push(orphan)
2691
3079
  } catch (err) {
2692
3080
  notes.push(err instanceof Error ? err.message : String(err))
@@ -2705,14 +3093,13 @@ export function handleSessionEnd(
2705
3093
  )
2706
3094
  }
2707
3095
  if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
2708
- const preserved: SessionState = {}
2709
- if (state.accepted !== undefined) preserved.accepted = state.accepted
2710
- if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
2711
- preserved.acknowledgement_due = state.acknowledgement_due
3096
+ const preserved: SessionState = { ...state }
3097
+ delete preserved.pending
3098
+ delete preserved.retiring
3099
+ delete preserved.acknowledgement_blocks
3100
+ if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
3101
+ delete preserved.acknowledgement_due
2712
3102
  }
2713
- if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
2714
- if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
2715
- if (state.continuation !== undefined) preserved.continuation = state.continuation
2716
3103
  writeSessionState(sessionId, env, preserved)
2717
3104
  notes.push(
2718
3105
  state.accepted !== undefined
@@ -2738,13 +3125,23 @@ export function handleSessionEnd(
2738
3125
  // ---------------------------------------------------------------------------
2739
3126
 
2740
3127
  /**
2741
- * More pending questions than this means an agent is looping, not asking.
2742
- * Related questions belong in one `ask --form` (the wire carries up to four
2743
- * questions per notification); four separate pushes is already a lot of lock
2744
- * screen.
3128
+ * Registrations still waiting for their first push. More than this in one turn
3129
+ * means an agent is looping, not asking, and related questions belong in one
3130
+ * `ask --form` instead.
3131
+ *
3132
+ * Deliberately counts only unasked entries. A question already on the user's
3133
+ * devices is waiting on a person, not on the agent, and an answer stays
3134
+ * accepted for a day — so counting live questions here would have turned a
3135
+ * patient user into a reason the agent could no longer ask anything.
2745
3136
  */
2746
3137
  export const MAX_PENDING_QUESTIONS = 4
2747
3138
 
3139
+ /**
3140
+ * Everything this session has open at once, asked or not. The lock screen is
3141
+ * the shared resource being protected here, not the agent's turn.
3142
+ */
3143
+ export const MAX_LIVE_QUESTIONS = 10
3144
+
2748
3145
  /**
2749
3146
  * A session may hold several registered questions at once: a new `ask` never
2750
3147
  * ends an earlier one. Each reaches the user as its own notification and is
@@ -2763,11 +3160,16 @@ export function registerQuestion(
2763
3160
  question: PendingQuestion,
2764
3161
  now: number = Date.now(),
2765
3162
  ): void {
2766
- let full = false
3163
+ let full: 'unasked' | 'live' | null = null
2767
3164
  updateSessionState(sessionId, env, (state) => {
2768
3165
  const pending = pendingList(state)
2769
- if (pending.length >= MAX_PENDING_QUESTIONS) {
2770
- full = true
3166
+ const unasked = pending.filter((entry) => entry.request_id === undefined)
3167
+ if (unasked.length >= MAX_PENDING_QUESTIONS) {
3168
+ full = 'unasked'
3169
+ return state
3170
+ }
3171
+ if (pending.length >= MAX_LIVE_QUESTIONS) {
3172
+ full = 'live'
2771
3173
  return state
2772
3174
  }
2773
3175
  return {
@@ -2783,12 +3185,18 @@ export function registerQuestion(
2783
3185
  ],
2784
3186
  }
2785
3187
  })
2786
- if (full) {
3188
+ if (full === 'unasked') {
2787
3189
  throw new Error(
2788
3190
  `${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
2789
3191
  'Combine related questions into one `notifai ask --form` instead of registering more.',
2790
3192
  )
2791
3193
  }
3194
+ if (full === 'live') {
3195
+ throw new Error(
3196
+ `${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
3197
+ 'Retire the ones you no longer need with `notifai close <request_id>` before asking another.',
3198
+ )
3199
+ }
2792
3200
  }
2793
3201
 
2794
3202
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */