@raidiant/notifai 7.0.2 → 9.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
package/src/hooks.test.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  MAX_CONTINUATION_COUNT,
51
51
  MAX_HELD_DELIVERIES,
52
52
  orphanRetirements,
53
+ pendingAnsweredByPrompt,
53
54
  pruneAbandonedSessions,
54
55
  releaseQuestionPush,
55
56
  readMatchingProjectSessionPointer,
@@ -127,6 +128,8 @@ interface Recorder {
127
128
  closed: string[]
128
129
  /** Simulates an offline machine; retirement has to survive one. */
129
130
  failSubmits?: boolean
131
+ /** Simulates an unreachable close fence so retirement stays queued. */
132
+ failCloses?: boolean
130
133
  /** Runs while an agent_question submit is in flight, before onSubmitted. */
131
134
  beforeQuestionSubmit?: () => void
132
135
  /** Lets race tests hold every replies call until all contenders are polling. */
@@ -142,6 +145,16 @@ interface Recorder {
142
145
  acknowledgementChecks?: string[]
143
146
  }
144
147
 
148
+ function isQuestionSubmit(entry: { draft: { reply?: unknown } }): boolean {
149
+ return entry.draft.reply !== undefined
150
+ }
151
+
152
+ function isRetirementSubmit(entry: {
153
+ draft: { lifecycle?: { retires_request_id?: string } }
154
+ }): boolean {
155
+ return entry.draft.lifecycle?.retires_request_id !== undefined
156
+ }
157
+
145
158
  function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
146
159
  let submissions = 0
147
160
  const recordedReplies = (requestId: string): ReplyView[] => {
@@ -157,7 +170,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
157
170
  return {
158
171
  beginPairing: notUsed,
159
172
  pollPairing: notUsed,
160
- // Both current companion apps register reply categories.
173
+ // Current reply-capable Companion fixtures.
161
174
  listDevices: async () => ({
162
175
  devices: [
163
176
  {
@@ -215,7 +228,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
215
228
  health: async () => true,
216
229
  submit: async (body) => {
217
230
  if (recorder.failSubmits === true) throw new Error('offline')
218
- if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
231
+ if (body.draft.reply !== undefined) recorder.beforeQuestionSubmit?.()
219
232
  recorder.submitted.push(body)
220
233
  submissions += 1
221
234
  const requestId = body.request_id ?? `req_hook_${submissions}`
@@ -250,6 +263,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
250
263
  }) satisfies ListRepliesResponse
251
264
  },
252
265
  closeReplies: async (requestId) => {
266
+ if (recorder.failCloses === true) throw new Error('offline')
253
267
  recorder.closed.push(requestId)
254
268
  return {
255
269
  request_id: requestId,
@@ -555,6 +569,38 @@ describe('pushing a registered question', () => {
555
569
  expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
556
570
  })
557
571
 
572
+ it('routes a registered question to an answer-capable Android installation', async () => {
573
+ const h = harness([reply({ text: 'Yes' })])
574
+ const client = {
575
+ ...fakeClient(h.recorder, [reply({ text: 'Yes' })]),
576
+ listDevices: async () => ({
577
+ devices: [
578
+ {
579
+ device_id: 'dev_android',
580
+ display_name: 'Pixel',
581
+ platform: 'android' as const,
582
+ permission_status: 'authorized',
583
+ registration_healthy: true,
584
+ capabilities: ['answer'],
585
+ derived_status: 'working',
586
+ last_seen_at: null,
587
+ },
588
+ ],
589
+ }),
590
+ } as ApiClient
591
+ const deps = { ...h.deps, clientFactory: () => client }
592
+ writeSessionState('android-answer', h.env, { last_prompt_at: AWAY })
593
+ registerQuestion('android-answer', h.env, { question: 'Ship it?' }, NOW)
594
+
595
+ await hookRunCommand(deps, 'stop', stdin({ session_id: 'android-answer' }))
596
+
597
+ expect(h.recorder.submitted[0]?.draft.targets).toEqual({
598
+ mode: 'selected',
599
+ device_ids: ['dev_android'],
600
+ })
601
+ expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBe('attention')
602
+ })
603
+
558
604
  it('keeps sparse alt text paired with its original media item', async () => {
559
605
  const h = harness([reply({ text: 'Yes' })])
560
606
  writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
@@ -704,7 +750,7 @@ describe('what the hook leaves behind', () => {
704
750
  )
705
751
 
706
752
  const raw = readFileSync(activeLogPath(h.env), 'utf8')
707
- expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
753
+ expect(raw).not.toContain('Allow exactly once')
708
754
  const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
709
755
  expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
710
756
  'queued',
@@ -835,7 +881,7 @@ describe('terminal-first grace window', () => {
835
881
 
836
882
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
837
883
 
838
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
884
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
839
885
  expect(submittedAt).toBe(NOW)
840
886
  })
841
887
 
@@ -868,7 +914,7 @@ describe('terminal-first grace window', () => {
868
914
 
869
915
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
870
916
 
871
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
917
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
872
918
  expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
873
919
  })
874
920
 
@@ -897,7 +943,7 @@ describe('terminal-first grace window', () => {
897
943
  expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
898
944
  expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
899
945
  expect(
900
- h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
946
+ h.recorder.submitted.find((entry) => isQuestionSubmit(entry))?.draft.reply
901
947
  ?.expires_in_seconds,
902
948
  ).toBeGreaterThanOrEqual(60)
903
949
  })
@@ -1006,7 +1052,7 @@ describe('nagging guards', () => {
1006
1052
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
1007
1053
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
1008
1054
 
1009
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
1055
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
1010
1056
  })
1011
1057
 
1012
1058
  it('respects the harness recursion guard', async () => {
@@ -1034,7 +1080,7 @@ describe('nagging guards', () => {
1034
1080
 
1035
1081
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
1036
1082
 
1037
- const questions = h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')
1083
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
1038
1084
  expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
1039
1085
  'First question?',
1040
1086
  'Follow-up question?',
@@ -1091,7 +1137,7 @@ describe('late answer collection', () => {
1091
1137
 
1092
1138
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
1093
1139
 
1094
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
1140
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1095
1141
  expect(h.recorder.closed).toContain('req_live')
1096
1142
  expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
1097
1143
  expect(h.io.outLines).toHaveLength(1)
@@ -1138,14 +1184,10 @@ describe('late answer collection', () => {
1138
1184
  stdin({ session_id: 'late-prompt', stop_hook_active: true }),
1139
1185
  )
1140
1186
 
1141
- const retirement = h.recorder.submitted.find(
1142
- (entry) => entry.draft.event === 'question_retired',
1143
- )?.draft
1144
- expect(retirement?.lifecycle).toMatchObject({
1145
- state: 'answered',
1146
- retires_request_id: 'req_live',
1147
- })
1148
- expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
1187
+ expect(h.recorder.closed).toContain('req_live')
1188
+ expect(
1189
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
1190
+ ).toHaveLength(0)
1149
1191
  expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
1150
1192
  expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
1151
1193
  })
@@ -1159,10 +1201,10 @@ describe('OpenCode answer continuation', () => {
1159
1201
 
1160
1202
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
1161
1203
 
1162
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
1204
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1163
1205
  expect(h.recorder.closed).toEqual([])
1164
1206
  expect(
1165
- h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
1207
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
1166
1208
  ).toHaveLength(0)
1167
1209
  expect(readSessionState('open1', h.env).pending).toBeUndefined()
1168
1210
  expect(h.io.outLines).toEqual([])
@@ -1236,7 +1278,7 @@ describe('the waiter owning one question to the end', () => {
1236
1278
  return {
1237
1279
  ...client,
1238
1280
  submit: async (body: SubmitNotificationRequestT) => {
1239
- if (body.draft.event !== 'agent_question') return client.submit(body, 0)
1281
+ if (body.draft.reply === undefined) return client.submit(body, 0)
1240
1282
  acceptedRequestId = body.request_id
1241
1283
  h.recorder.submitted.push(body)
1242
1284
  h.recorder.receipts.push(body.request_id!)
@@ -1464,7 +1506,7 @@ describe('the waiter owning one question to the end', () => {
1464
1506
 
1465
1507
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
1466
1508
 
1467
- expect(h.deps.now?.()).toBe(NOW + 60_000)
1509
+ expect(h.deps.now?.()).toBe(NOW + 57_000)
1468
1510
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1469
1511
  expect(readSessionState('deadline', h.env).pending).toBeUndefined()
1470
1512
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
@@ -1480,7 +1522,7 @@ describe('the waiter owning one question to the end', () => {
1480
1522
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
1481
1523
 
1482
1524
  const windows = h.recorder.submitted
1483
- .filter((entry) => entry.draft.event === 'agent_question')
1525
+ .filter((entry) => isQuestionSubmit(entry))
1484
1526
  .map((entry) => entry.draft.reply?.expires_in_seconds)
1485
1527
  // How long the user may answer does not shrink because this owner spent
1486
1528
  // part of its own budget getting the question out. Both questions stay
@@ -1520,7 +1562,116 @@ describe('the waiter owning one question to the end', () => {
1520
1562
 
1521
1563
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
1522
1564
  expect(readSessionState('successor', h.env).pending).toBeUndefined()
1523
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(2)
1565
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
1566
+ })
1567
+
1568
+ it('does not start another waiter for a live question whose owner lease is already spent', async () => {
1569
+ const h = harness([])
1570
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1571
+ writeSessionState('spent-owner', h.env, {
1572
+ last_prompt_at: AWAY,
1573
+ pending: [
1574
+ {
1575
+ question: 'Old question?',
1576
+ asked_at: NOW - 3_300_000,
1577
+ request_id: 'req_old',
1578
+ collapse_key: 'question-old',
1579
+ device_ids: ['dev_iphone'],
1580
+ reply_deadline_at: NOW + 86_400_000,
1581
+ owner_deadline_at: NOW - 1,
1582
+ },
1583
+ ],
1584
+ })
1585
+
1586
+ const started = h.deps.now?.() ?? NOW
1587
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'spent-owner' }))
1588
+
1589
+ expect(h.deps.now?.()).toBeLessThan(started + 30_000)
1590
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1591
+ expect(readSessionState('spent-owner', h.env).pending?.[0]?.request_id).toBe('req_old')
1592
+ expect(h.io.errLines.join('\n')).toContain('previous waiter already used its ceiling')
1593
+ })
1594
+
1595
+ it('does not re-arm a live question written before owner leases were persisted', async () => {
1596
+ const h = harness([])
1597
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1598
+ writeSessionState('legacy-spent', h.env, {
1599
+ last_prompt_at: AWAY,
1600
+ pending: [
1601
+ {
1602
+ question: 'Degraded waiter leftover?',
1603
+ asked_at: NOW - 3_300_000,
1604
+ request_id: 'req_ATU4_legacy',
1605
+ collapse_key: 'question-legacy',
1606
+ device_ids: ['dev_iphone'],
1607
+ reply_deadline_at: NOW + 86_400_000,
1608
+ },
1609
+ ],
1610
+ })
1611
+
1612
+ const started = h.deps.now?.() ?? NOW
1613
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'legacy-spent' }))
1614
+
1615
+ expect(h.deps.now?.()).toBeLessThan(started + 30_000)
1616
+ expect(readSessionState('legacy-spent', h.env).pending?.[0]?.request_id).toBe(
1617
+ 'req_ATU4_legacy',
1618
+ )
1619
+ })
1620
+
1621
+ it('pushes a newly registered question on the next Stop after a spent waiter', async () => {
1622
+ const h = harness([])
1623
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1624
+ writeSessionState('after-degraded', h.env, {
1625
+ last_prompt_at: AWAY,
1626
+ pending: [
1627
+ {
1628
+ question: 'Earlier question?',
1629
+ asked_at: NOW - 3_300_000,
1630
+ request_id: 'req_earlier',
1631
+ collapse_key: 'question-earlier',
1632
+ device_ids: ['dev_iphone'],
1633
+ reply_deadline_at: NOW + 86_400_000,
1634
+ },
1635
+ ],
1636
+ })
1637
+
1638
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
1639
+ registerQuestion('after-degraded', h.env, { question: 'New question?' }, h.deps.now?.())
1640
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
1641
+
1642
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
1643
+ expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['New question?'])
1644
+ expect(readSessionState('after-degraded', h.env).pending?.map((entry) => entry.question)).toEqual(
1645
+ ['Earlier question?', 'New question?'],
1646
+ )
1647
+ })
1648
+
1649
+ it('still waits out a live owner lease for an independent answer', async () => {
1650
+ const h = harness([])
1651
+ const answerAt = NOW + 10_000
1652
+ h.recorder.repliesFor = (requestId) =>
1653
+ requestId === 'req_live' && (h.deps.now?.() ?? 0) >= answerAt
1654
+ ? [reply({ text: 'Later' })]
1655
+ : []
1656
+ writeSessionState('lease', h.env, {
1657
+ last_prompt_at: AWAY,
1658
+ pending: [
1659
+ {
1660
+ question: 'Still waiting?',
1661
+ asked_at: NOW,
1662
+ request_id: 'req_live',
1663
+ collapse_key: 'question-live',
1664
+ device_ids: ['dev_iphone'],
1665
+ reply_deadline_at: NOW + 86_400_000,
1666
+ owner_deadline_at: NOW + 60_000,
1667
+ },
1668
+ ],
1669
+ })
1670
+
1671
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'lease' }))
1672
+
1673
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Later')
1674
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(answerAt)
1524
1675
  })
1525
1676
  })
1526
1677
 
@@ -1556,14 +1707,9 @@ describe('Cursor stop output', () => {
1556
1707
  * questions piling up on a phone teach the user to ignore the surface.
1557
1708
  */
1558
1709
  describe('question queueing and retirement debt', () => {
1559
- /** The lifecycle state of each retirement push the recorder captured. */
1560
- function retirements(h: Harness): { state: unknown; retires: unknown }[] {
1561
- return h.recorder.submitted
1562
- .filter((s) => s.draft.event === 'question_retired')
1563
- .map((s) => ({
1564
- state: s.draft.lifecycle?.state,
1565
- retires: s.draft.lifecycle?.retires_request_id,
1566
- }))
1710
+ /** Request ids whose close fence proved retirement. */
1711
+ function retirements(h: Harness): string[] {
1712
+ return h.recorder.closed
1567
1713
  }
1568
1714
 
1569
1715
  function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
@@ -1603,13 +1749,17 @@ describe('question queueing and retirement debt', () => {
1603
1749
 
1604
1750
  // Offline when the user returns: the wipe parks the retirement, the
1605
1751
  // drain fails, and it must not forget what it was for.
1606
- h.recorder.failSubmits = true
1607
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup2' }))
1752
+ h.recorder.failCloses = true
1753
+ await hookRunCommand(
1754
+ h.deps,
1755
+ 'user-prompt-submit',
1756
+ stdin({ session_id: 'sup2', prompt: 'done' }),
1757
+ )
1608
1758
  expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
1609
1759
 
1610
- h.recorder.failSubmits = false
1760
+ h.recorder.failCloses = false
1611
1761
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
1612
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1762
+ expect(retirements(h)).toContain(first)
1613
1763
  expect(readSessionState('sup2', h.env).retiring).toEqual([])
1614
1764
  })
1615
1765
 
@@ -1629,16 +1779,22 @@ describe('question queueing and retirement debt', () => {
1629
1779
  const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
1630
1780
  mkdirSync(configDir, { recursive: true })
1631
1781
  writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
1632
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
1633
-
1634
- const retirement = h.recorder.submitted.find(
1635
- (submission) => submission.draft.event === 'question_retired',
1782
+ h.recorder.failCloses = true
1783
+ await hookRunCommand(
1784
+ h.deps,
1785
+ 'user-prompt-submit',
1786
+ stdin({ session_id: 'sup-targets', prompt: 'done' }),
1636
1787
  )
1637
- expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
1638
- expect(retirement?.draft.targets).toEqual({
1639
- mode: 'selected',
1640
- device_ids: ['dev_iphone', 'dev_mac'],
1641
- })
1788
+
1789
+ expect(readSessionState('sup-targets', h.env).retiring).toEqual([
1790
+ expect.objectContaining({
1791
+ request_id: 'req_live',
1792
+ device_ids: ['dev_iphone', 'dev_mac'],
1793
+ }),
1794
+ ])
1795
+ expect(
1796
+ h.recorder.submitted.filter((submission) => isRetirementSubmit(submission)),
1797
+ ).toHaveLength(0)
1642
1798
  })
1643
1799
 
1644
1800
  it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
@@ -1649,14 +1805,18 @@ describe('question queueing and retirement debt', () => {
1649
1805
  seedLive(h, 'sup3')
1650
1806
  const first = 'req_live'
1651
1807
 
1652
- h.recorder.failSubmits = true
1653
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
1808
+ h.recorder.failCloses = true
1809
+ await hookRunCommand(
1810
+ h.deps,
1811
+ 'user-prompt-submit',
1812
+ stdin({ session_id: 'sup3', prompt: 'done' }),
1813
+ )
1654
1814
  expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
1655
- h.recorder.failSubmits = false
1815
+ h.recorder.failCloses = false
1656
1816
 
1657
1817
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
1658
1818
 
1659
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1819
+ expect(retirements(h)).toContain(first)
1660
1820
  expect(readSessionState('sup3', h.env).retiring).toEqual([])
1661
1821
  })
1662
1822
 
@@ -1669,13 +1829,21 @@ describe('question queueing and retirement debt', () => {
1669
1829
 
1670
1830
  // The first return is offline: the wipe parks, the drain fails, and the
1671
1831
  // next prompt must still find the debt.
1672
- h.recorder.failSubmits = true
1673
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
1674
- h.recorder.failSubmits = false
1832
+ h.recorder.failCloses = true
1833
+ await hookRunCommand(
1834
+ h.deps,
1835
+ 'user-prompt-submit',
1836
+ stdin({ session_id: 'sup4', prompt: 'done' }),
1837
+ )
1838
+ h.recorder.failCloses = false
1675
1839
 
1676
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
1840
+ await hookRunCommand(
1841
+ h.deps,
1842
+ 'user-prompt-submit',
1843
+ stdin({ session_id: 'sup4', prompt: 'ok' }),
1844
+ )
1677
1845
 
1678
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1846
+ expect(retirements(h)).toContain(first)
1679
1847
  expect(readSessionState('sup4', h.env).retiring).toEqual([])
1680
1848
  })
1681
1849
 
@@ -1709,7 +1877,7 @@ describe('several questions in flight', () => {
1709
1877
 
1710
1878
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
1711
1879
 
1712
- const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1880
+ const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
1713
1881
  expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
1714
1882
  // Each is its own notification with its own collapse key — one ask never
1715
1883
  // stands in for, or replaces, another.
@@ -1753,7 +1921,7 @@ describe('several questions in flight', () => {
1753
1921
  'codex',
1754
1922
  )
1755
1923
 
1756
- expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
1924
+ expect(h.recorder.submitted.filter((s) => isQuestionSubmit(s))).toHaveLength(1)
1757
1925
  expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
1758
1926
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1759
1927
  expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
@@ -1787,7 +1955,7 @@ describe('several questions in flight', () => {
1787
1955
 
1788
1956
  // Submit recorded the durable id before any wait; a 500 must not erase it
1789
1957
  // mid-flight, and recovery must still surface the late answer.
1790
- const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1958
+ const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
1791
1959
  expect(questions).toHaveLength(1)
1792
1960
  expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1793
1961
  expect(polls).toBeGreaterThanOrEqual(3)
@@ -1927,13 +2095,10 @@ describe('several questions in flight', () => {
1927
2095
  )
1928
2096
  expect(h.io.outLines).toEqual([])
1929
2097
  expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
2098
+ expect(h.recorder.closed).toContain('req_existing')
1930
2099
  expect(
1931
- h.recorder.submitted.find(
1932
- (entry) =>
1933
- entry.draft.event === 'question_retired' &&
1934
- entry.draft.lifecycle?.retires_request_id === 'req_existing',
1935
- )?.draft.lifecycle?.state,
1936
- ).toBe('answered')
2100
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
2101
+ ).toHaveLength(0)
1937
2102
  })
1938
2103
 
1939
2104
  it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
@@ -2123,19 +2288,14 @@ describe('several questions in flight', () => {
2123
2288
  expect(followup.reason).toContain('Staging')
2124
2289
  expect(readSessionState('multi3', h.env).pending).toBeUndefined()
2125
2290
  expect(
2126
- h.recorder.submitted.filter((s) => s.draft.event === 'agent_question'),
2291
+ h.recorder.submitted.filter((s) => isQuestionSubmit(s)),
2127
2292
  ).toHaveLength(2)
2128
2293
  })
2129
2294
  })
2130
2295
 
2131
2296
  describe('a question that outlives its session', () => {
2132
- function retirements(h: Harness): { state: unknown; retires: unknown }[] {
2133
- return h.recorder.submitted
2134
- .filter((s) => s.draft.event === 'question_retired')
2135
- .map((s) => ({
2136
- state: s.draft.lifecycle?.state,
2137
- retires: s.draft.lifecycle?.retires_request_id,
2138
- }))
2297
+ function retirements(h: Harness): string[] {
2298
+ return h.recorder.closed
2139
2299
  }
2140
2300
 
2141
2301
  /** State a still-owned request can have when SessionEnd races its Stop. */
@@ -2166,7 +2326,7 @@ describe('a question that outlives its session', () => {
2166
2326
  // A different session's next hook holds a client and inherits the debt.
2167
2327
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
2168
2328
  expect(h.recorder.closed).toContain(first)
2169
- expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
2329
+ expect(retirements(h)).toContain(first)
2170
2330
  })
2171
2331
 
2172
2332
  it('carries parked retirements across SessionEnd too', async () => {
@@ -2175,13 +2335,17 @@ describe('a question that outlives its session', () => {
2175
2335
 
2176
2336
  // Died while offline: the user came back, the wipe parked the
2177
2337
  // retirement, and the drain could not send it.
2178
- h.recorder.failSubmits = true
2179
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
2180
- h.recorder.failSubmits = false
2338
+ h.recorder.failCloses = true
2339
+ await hookRunCommand(
2340
+ h.deps,
2341
+ 'user-prompt-submit',
2342
+ stdin({ session_id: 'dead2', prompt: 'done' }),
2343
+ )
2344
+ h.recorder.failCloses = false
2181
2345
 
2182
2346
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
2183
2347
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
2184
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
2348
+ expect(retirements(h)).toContain(first)
2185
2349
  })
2186
2350
 
2187
2351
  it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
@@ -2190,16 +2354,16 @@ describe('a question that outlives its session', () => {
2190
2354
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
2191
2355
 
2192
2356
  // Still offline: the queue must survive a failed drain.
2193
- h.recorder.failSubmits = true
2357
+ h.recorder.failCloses = true
2194
2358
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2195
- h.recorder.failSubmits = false
2359
+ h.recorder.failCloses = false
2196
2360
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2197
- expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
2361
+ expect(retirements(h)).toContain(first)
2198
2362
 
2199
2363
  // And a second drain does not send it twice.
2200
- const count = retirements(h).filter((r) => r.retires === first).length
2364
+ const count = retirements(h).filter((id) => id === first).length
2201
2365
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2202
- expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
2366
+ expect(retirements(h).filter((id) => id === first)).toHaveLength(count)
2203
2367
  })
2204
2368
 
2205
2369
  it('gives up on an orphan that outlived the longest answerable window', async () => {
@@ -2323,9 +2487,9 @@ describe('ask registration', () => {
2323
2487
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
2324
2488
  const draft = h.recorder.submitted[0]?.draft
2325
2489
  expect(draft?.presentation.title).toBe('Deploy where?')
2326
- expect(draft?.presentation.body).toBe(
2327
- '1. Deploy where?\n2. Anything to watch?\n\n## Context\nThe long story.',
2328
- )
2490
+ // The questions travel structured and the first one is the title; the
2491
+ // body carries only the context so nothing renders twice.
2492
+ expect(draft?.presentation.body).toBe('## Context\nThe long story.')
2329
2493
  expect(draft?.reply?.questions).toEqual([
2330
2494
  {
2331
2495
  id: 'deploy-where',
@@ -2466,7 +2630,347 @@ describe('ask registration', () => {
2466
2630
  })
2467
2631
  })
2468
2632
 
2633
+ describe('session-start hook', () => {
2634
+ it('adds proactive Notifai context before setup or machine authentication exists', async () => {
2635
+ const h = harness()
2636
+ h.deps.store.load = () => null
2637
+
2638
+ for (const hookHarness of ['claude-code', 'codex'] as const) {
2639
+ h.io.outLines = []
2640
+ await hookRunCommand(
2641
+ h.deps,
2642
+ 'session-start',
2643
+ stdin({ session_id: `proactive-guidance-${hookHarness}`, source: 'startup' }),
2644
+ hookHarness,
2645
+ )
2646
+
2647
+ const first = JSON.parse(h.io.outLines.at(-1)!) as {
2648
+ hookSpecificOutput?: { hookEventName?: string; additionalContext?: string }
2649
+ }
2650
+ expect(first.hookSpecificOutput).toMatchObject({
2651
+ hookEventName: 'SessionStart',
2652
+ })
2653
+ expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*session/i)
2654
+ expect(first.hookSpecificOutput?.additionalContext).toMatch(
2655
+ /before beginning task work.*use the Notifai skill.*run `notifai guidance`/i,
2656
+ )
2657
+ expect(first.hookSpecificOutput?.additionalContext).toMatch(/`notifai guidance`/i)
2658
+ expect(first.hookSpecificOutput?.additionalContext).toMatch(/did not mention/i)
2659
+ expect(h.io.errLines.join('\n')).not.toMatch(/not paired/i)
2660
+ }
2661
+ })
2662
+
2663
+ it('uses each supported lifecycle output contract and gives workers deterministic ownership', async () => {
2664
+ const h = harness()
2665
+ h.deps.store.load = () => null
2666
+
2667
+ await hookRunCommand(
2668
+ h.deps,
2669
+ 'session-start',
2670
+ stdin({ conversation_id: 'cursor-session', workspace_roots: [h.deps.cwd] }),
2671
+ 'cursor',
2672
+ )
2673
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2674
+ additional_context: expect.stringMatching(/Notifai.*session/i),
2675
+ })
2676
+
2677
+ h.io.outLines = []
2678
+ await hookRunCommand(
2679
+ h.deps,
2680
+ 'subagent-start',
2681
+ stdin({ session_id: 'subagent-session' }),
2682
+ 'claude-code',
2683
+ )
2684
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2685
+ hookSpecificOutput: {
2686
+ hookEventName: 'SubagentStart',
2687
+ additionalContext: expect.stringMatching(
2688
+ /parent owns.*unless it explicitly delegates.*workers.*do not send independently/i,
2689
+ ),
2690
+ },
2691
+ })
2692
+ })
2693
+
2694
+ it('uses Cursor native Stop follow-up once when its SessionStart context is lossy', async () => {
2695
+ const h = harness()
2696
+ h.deps.store.load = () => null
2697
+ const input = stdin({
2698
+ conversation_id: 'cursor-activation',
2699
+ workspace_roots: [h.deps.cwd],
2700
+ loop_count: 0,
2701
+ })
2702
+
2703
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2704
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2705
+ followup_message: expect.stringMatching(/before finalizing.*turn that just ended.*notifai guidance/i),
2706
+ })
2707
+
2708
+ h.io.outLines = []
2709
+ await hookRunCommand(
2710
+ h.deps,
2711
+ 'activation-stop',
2712
+ stdin({
2713
+ conversation_id: 'cursor-activation',
2714
+ workspace_roots: [h.deps.cwd],
2715
+ loop_count: 1,
2716
+ }),
2717
+ 'cursor',
2718
+ )
2719
+ expect(h.io.outLines).toEqual([])
2720
+ expect(readSessionState('cursor-activation', h.env).cursor_activation_confirmed_at).toBe(NOW)
2721
+
2722
+ await hookRunCommand(
2723
+ h.deps,
2724
+ 'session-start',
2725
+ stdin({ conversation_id: 'cursor-activation', workspace_roots: [h.deps.cwd] }),
2726
+ 'cursor',
2727
+ )
2728
+ h.io.outLines = []
2729
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2730
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2731
+ })
2732
+
2733
+ it('does not let Cursor activation override cancellation or a live question continuation', async () => {
2734
+ const h = harness()
2735
+ const base = {
2736
+ conversation_id: 'cursor-coexistence',
2737
+ workspace_roots: [h.deps.cwd],
2738
+ loop_count: 0,
2739
+ }
2740
+
2741
+ await hookRunCommand(
2742
+ h.deps,
2743
+ 'activation-stop',
2744
+ stdin({ ...base, status: 'aborted' }),
2745
+ 'cursor',
2746
+ )
2747
+ expect(h.io.outLines).toEqual([])
2748
+
2749
+ registerQuestion('cursor-coexistence', h.env, { question: 'Choose a path?' })
2750
+ await hookRunCommand(
2751
+ h.deps,
2752
+ 'activation-stop',
2753
+ stdin({ ...base, status: 'completed' }),
2754
+ 'cursor',
2755
+ )
2756
+ expect(h.io.outLines).toEqual([])
2757
+ expect(readSessionState('cursor-coexistence', h.env).cursor_activation_claimed_at).toBeUndefined()
2758
+
2759
+ await hookRunCommand(
2760
+ h.deps,
2761
+ 'activation-stop',
2762
+ stdin({ ...base, status: 'completed', loop_count: 1 }),
2763
+ 'cursor',
2764
+ )
2765
+ expect(h.io.outLines).toEqual([])
2766
+ expect(readSessionState('cursor-coexistence', h.env).cursor_activation_confirmed_at).toBeUndefined()
2767
+ })
2768
+
2769
+ it('retries an unconfirmed Cursor activation claim after the crash guard expires', async () => {
2770
+ const h = harness()
2771
+ const input = stdin({
2772
+ conversation_id: 'cursor-activation-retry',
2773
+ workspace_roots: [h.deps.cwd],
2774
+ status: 'completed',
2775
+ loop_count: 0,
2776
+ })
2777
+
2778
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2779
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2780
+
2781
+ h.io.outLines = []
2782
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2783
+ expect(h.io.outLines).toEqual([])
2784
+
2785
+ h.advanceClock(30_001)
2786
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2787
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2788
+ })
2789
+
2790
+ it('fails closed without a Cursor conversation id and still activates after an errored turn', async () => {
2791
+ const h = harness()
2792
+
2793
+ await hookRunCommand(
2794
+ h.deps,
2795
+ 'activation-stop',
2796
+ stdin({ workspace_roots: [h.deps.cwd], status: 'completed', loop_count: 0 }),
2797
+ 'cursor',
2798
+ )
2799
+ expect(h.io.outLines).toEqual([])
2800
+
2801
+ await hookRunCommand(
2802
+ h.deps,
2803
+ 'activation-stop',
2804
+ stdin({
2805
+ conversation_id: 'cursor-error-activation',
2806
+ workspace_roots: [h.deps.cwd],
2807
+ status: 'error',
2808
+ loop_count: 0,
2809
+ }),
2810
+ 'cursor',
2811
+ )
2812
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2813
+ })
2814
+
2815
+ it('makes a Claude compatibility copy inside Cursor a no-op', async () => {
2816
+ const h = harness()
2817
+ h.deps.env['CURSOR_PROJECT_DIR'] = h.deps.cwd
2818
+
2819
+ await hookRunCommand(
2820
+ h.deps,
2821
+ 'session-start',
2822
+ stdin({ session_id: 'cursor-compat-copy', source: 'startup' }),
2823
+ 'claude-code',
2824
+ )
2825
+
2826
+ expect(h.io.outLines).toEqual([])
2827
+ })
2828
+ })
2829
+
2469
2830
  describe('user-prompt-submit hook', () => {
2831
+ it('activates once as a compatibility fallback when SessionStart was absent', async () => {
2832
+ const h = harness()
2833
+
2834
+ await hookRunCommand(
2835
+ h.deps,
2836
+ 'user-prompt-submit',
2837
+ stdin({ session_id: 'presence-only' }),
2838
+ 'claude-code',
2839
+ )
2840
+
2841
+ expect(h.io.outLines).toHaveLength(1)
2842
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2843
+ hookSpecificOutput: {
2844
+ hookEventName: 'UserPromptSubmit',
2845
+ additionalContext: expect.stringMatching(/Notifai.*session/i),
2846
+ },
2847
+ })
2848
+ expect(readSessionState('presence-only', h.env).last_prompt_at).toBe(NOW)
2849
+
2850
+ await hookRunCommand(
2851
+ h.deps,
2852
+ 'user-prompt-submit',
2853
+ stdin({ session_id: 'presence-only' }),
2854
+ 'claude-code',
2855
+ )
2856
+ expect(h.io.outLines).toHaveLength(1)
2857
+ })
2858
+
2859
+ it('stays silent when SessionStart already delivered activation', async () => {
2860
+ const h = harness()
2861
+ h.deps.codexSourcePid = 4242
2862
+
2863
+ await hookRunCommand(
2864
+ h.deps,
2865
+ 'session-start',
2866
+ stdin({ session_id: 'lifecycle-owned' }),
2867
+ 'codex',
2868
+ )
2869
+ h.io.outLines = []
2870
+
2871
+ await hookRunCommand(
2872
+ h.deps,
2873
+ 'user-prompt-submit',
2874
+ stdin({ session_id: 'lifecycle-owned' }),
2875
+ 'codex',
2876
+ )
2877
+
2878
+ expect(h.io.outLines).toEqual([])
2879
+ })
2880
+
2881
+ it('reactivates a resumed session owned by a new harness process', async () => {
2882
+ const h = harness()
2883
+ h.deps.codexSourcePid = 4242
2884
+ await hookRunCommand(
2885
+ h.deps,
2886
+ 'session-start',
2887
+ stdin({ session_id: 'resumed-owner' }),
2888
+ 'codex',
2889
+ )
2890
+ h.io.outLines = []
2891
+ h.deps.codexSourcePid = 4343
2892
+
2893
+ await hookRunCommand(
2894
+ h.deps,
2895
+ 'user-prompt-submit',
2896
+ stdin({ session_id: 'resumed-owner' }),
2897
+ 'codex',
2898
+ )
2899
+
2900
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2901
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2902
+ })
2903
+ })
2904
+
2905
+ it('uses the adapter-propagated harness owner across per-hook CLI processes', async () => {
2906
+ const h = harness()
2907
+ h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5252'
2908
+
2909
+ await hookRunCommand(
2910
+ h.deps,
2911
+ 'user-prompt-submit',
2912
+ stdin({ session_id: 'adapter-owned' }),
2913
+ 'codex',
2914
+ )
2915
+ h.io.outLines = []
2916
+ await hookRunCommand(
2917
+ h.deps,
2918
+ 'user-prompt-submit',
2919
+ stdin({ session_id: 'adapter-owned' }),
2920
+ 'codex',
2921
+ )
2922
+ expect(h.io.outLines).toEqual([])
2923
+
2924
+ h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5353'
2925
+ await hookRunCommand(
2926
+ h.deps,
2927
+ 'user-prompt-submit',
2928
+ stdin({ session_id: 'adapter-owned' }),
2929
+ 'codex',
2930
+ )
2931
+ expect(h.io.outLines).toHaveLength(1)
2932
+ })
2933
+
2934
+ it('does not let a worker lifecycle suppress parent prompt activation', async () => {
2935
+ const h = harness()
2936
+ h.deps.codexSourcePid = 4242
2937
+ await hookRunCommand(
2938
+ h.deps,
2939
+ 'subagent-start',
2940
+ stdin({ session_id: 'shared-parent-worker' }),
2941
+ 'codex',
2942
+ )
2943
+ h.io.outLines = []
2944
+
2945
+ await hookRunCommand(
2946
+ h.deps,
2947
+ 'user-prompt-submit',
2948
+ stdin({ session_id: 'shared-parent-worker' }),
2949
+ 'codex',
2950
+ )
2951
+
2952
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2953
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2954
+ })
2955
+ })
2956
+
2957
+ it('activates before config and authentication readiness', async () => {
2958
+ const h = harness()
2959
+ h.deps.cwd = path.join(h.deps.cwd, 'missing-project')
2960
+ h.deps.store.load = () => null
2961
+
2962
+ await hookRunCommand(
2963
+ h.deps,
2964
+ 'user-prompt-submit',
2965
+ stdin({ session_id: 'first-run', cwd: h.deps.cwd }),
2966
+ 'codex',
2967
+ )
2968
+
2969
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2970
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2971
+ })
2972
+ })
2973
+
2470
2974
  it('records presence', async () => {
2471
2975
  const h = harness()
2472
2976
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
@@ -2532,7 +3036,7 @@ describe('user-prompt-submit hook', () => {
2532
3036
  expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
2533
3037
  })
2534
3038
 
2535
- it('preserves a delivered question through a second ask and the prompt transition', async () => {
3039
+ it('preserves a delivered question through a second ask and an unrelated prompt', async () => {
2536
3040
  const h = harness([])
2537
3041
  const live = {
2538
3042
  question: 'Ship it?',
@@ -2543,10 +3047,6 @@ describe('user-prompt-submit hook', () => {
2543
3047
  }
2544
3048
  writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
2545
3049
 
2546
- // A second plain `ask` resolves through the project pointer and joins the
2547
- // queue behind the live question — it must not touch it. The next prompt
2548
- // then resets presence before another Stop can run — the transition that
2549
- // used to erase the retirement debt.
2550
3050
  registerQuestion('transition', h.env, { question: 'Deploy it?' })
2551
3051
  const queued = readSessionState('transition', h.env)
2552
3052
  expect(queued.retiring ?? []).toEqual([])
@@ -2555,35 +3055,22 @@ describe('user-prompt-submit hook', () => {
2555
3055
  await hookRunCommand(
2556
3056
  h.deps,
2557
3057
  'user-prompt-submit',
2558
- stdin({ session_id: 'transition', cwd: h.deps.cwd }),
3058
+ stdin({
3059
+ session_id: 'transition',
3060
+ cwd: h.deps.cwd,
3061
+ prompt: 'keep going on the billing work',
3062
+ }),
2559
3063
  'claude-code',
2560
3064
  )
2561
3065
 
2562
- // The delivered question retires truthfully. The never-delivered second
2563
- // one must remain queued: if Codex skipped Stop, erasing it here loses the
2564
- // only copy before it ever reached a device.
2565
- const retirement = h.recorder.submitted.find(
2566
- (submission) => submission.draft.event === 'question_retired',
2567
- )?.draft
2568
- expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
2569
- expect(retirement?.lifecycle).toEqual({
2570
- tier: 'done',
2571
- state: 'answered_elsewhere',
2572
- retires_request_id: live.request_id,
2573
- })
2574
- expect(retirement?.targets).toEqual({
2575
- mode: 'selected',
2576
- device_ids: ['dev_iphone', 'dev_mac'],
2577
- })
3066
+ expect(h.recorder.closed).toEqual([])
2578
3067
  expect(
2579
- h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
2580
- ).toHaveLength(1)
3068
+ h.recorder.submitted.filter((s) => isRetirementSubmit(s)),
3069
+ ).toHaveLength(0)
2581
3070
  expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
3071
+ 'Ship it?',
2582
3072
  'Deploy it?',
2583
3073
  ])
2584
-
2585
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
2586
- expect(readSessionState('transition', h.env).pending).toBeUndefined()
2587
3074
  })
2588
3075
 
2589
3076
  it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
@@ -2593,14 +3080,14 @@ describe('user-prompt-submit hook', () => {
2593
3080
 
2594
3081
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
2595
3082
  expect(readSessionState('s11', h.env).pending).toBeUndefined()
2596
- const retirementCount = h.recorder.submitted.filter(
2597
- (entry) => entry.draft.event === 'question_retired',
2598
- ).length
3083
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2599
3084
 
2600
3085
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
2601
3086
 
2602
3087
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2603
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired')).toHaveLength(retirementCount + 1)
3088
+ expect(
3089
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3090
+ ).toHaveLength(0)
2604
3091
  })
2605
3092
 
2606
3093
  it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
@@ -2619,16 +3106,16 @@ describe('user-prompt-submit hook', () => {
2619
3106
  }],
2620
3107
  })
2621
3108
 
2622
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
3109
+ await hookRunCommand(
3110
+ h.deps,
3111
+ 'user-prompt-submit',
3112
+ stdin({ session_id: 's15', cwd: '/repo', prompt: 'done' }),
3113
+ )
2623
3114
 
2624
- const retirement = h.recorder.submitted.at(-1)?.draft
2625
- expect(retirement?.lifecycle).toEqual({
2626
- tier: 'done',
2627
- state: 'answered_elsewhere',
2628
- // The history-entry correlation id: companions key entries by request
2629
- // id and never persisted the collapse key.
2630
- retires_request_id: 'req_live',
2631
- })
3115
+ expect(h.recorder.closed).toContain('req_live')
3116
+ expect(
3117
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3118
+ ).toHaveLength(0)
2632
3119
  })
2633
3120
 
2634
3121
  it('retires as done/answered when the answer came from a device', async () => {
@@ -2644,12 +3131,71 @@ describe('user-prompt-submit hook', () => {
2644
3131
  stdin({ session_id: 's16', stop_hook_active: true }),
2645
3132
  )
2646
3133
 
2647
- const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
2648
- expect(retirement?.draft.lifecycle).toEqual({
2649
- tier: 'done',
2650
- state: 'answered',
2651
- retires_request_id: h.recorder.receipts[0],
3134
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3135
+ expect(
3136
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3137
+ ).toHaveLength(0)
3138
+ })
3139
+
3140
+ it('retires only the outstanding question a prompt uniquely answers', async () => {
3141
+ const h = harness([])
3142
+ writeSessionState('prompt-match', h.env, {
3143
+ last_prompt_at: AWAY,
3144
+ pending: [
3145
+ {
3146
+ question: 'Ship it?',
3147
+ questions: [
3148
+ {
3149
+ id: 'q1',
3150
+ text: 'Ship it?',
3151
+ choices: [
3152
+ { id: 'ship', label: 'Ship it' },
3153
+ { id: 'wait', label: 'Wait' },
3154
+ ],
3155
+ },
3156
+ ],
3157
+ request_id: 'req_live',
3158
+ collapse_key: 'collapse-live',
3159
+ device_ids: ['dev_iphone'],
3160
+ reply_deadline_at: NOW + 60_000,
3161
+ },
3162
+ ],
3163
+ })
3164
+
3165
+ await hookRunCommand(
3166
+ h.deps,
3167
+ 'user-prompt-submit',
3168
+ stdin({ session_id: 'prompt-match', prompt: 'Ship it' }),
3169
+ )
3170
+
3171
+ expect(h.recorder.closed).toContain('req_live')
3172
+ expect(readSessionState('prompt-match', h.env).pending).toBeUndefined()
3173
+ })
3174
+
3175
+ it('leaves an unrelated prompt’s live question open for the reply window', async () => {
3176
+ const h = harness([])
3177
+ writeSessionState('prompt-unrelated', h.env, {
3178
+ last_prompt_at: AWAY,
3179
+ pending: [{
3180
+ question: 'Ship it?',
3181
+ request_id: 'req_live',
3182
+ collapse_key: 'collapse-live',
3183
+ device_ids: ['dev_iphone'],
3184
+ reply_deadline_at: NOW + 60_000,
3185
+ }],
2652
3186
  })
3187
+
3188
+ await hookRunCommand(
3189
+ h.deps,
3190
+ 'user-prompt-submit',
3191
+ stdin({
3192
+ session_id: 'prompt-unrelated',
3193
+ prompt: 'continue investigating the billing failure on staging',
3194
+ }),
3195
+ )
3196
+
3197
+ expect(h.recorder.closed).toEqual([])
3198
+ expect(readSessionState('prompt-unrelated', h.env).pending?.[0]?.request_id).toBe('req_live')
2653
3199
  })
2654
3200
 
2655
3201
  it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
@@ -2730,6 +3276,83 @@ describe('user-prompt-submit hook', () => {
2730
3276
  })
2731
3277
  })
2732
3278
 
3279
+ describe('reconciling a conversation answer', () => {
3280
+ it('matches a unique closed choice and a lone short confirmation', () => {
3281
+ const choiceQuestion: PendingQuestion = {
3282
+ question: 'Ship it?',
3283
+ questions: [
3284
+ {
3285
+ id: 'q1',
3286
+ text: 'Ship it?',
3287
+ choices: [
3288
+ { id: 'ship', label: 'Ship it' },
3289
+ { id: 'wait', label: 'Wait' },
3290
+ ],
3291
+ },
3292
+ ],
3293
+ }
3294
+ const freeText: PendingQuestion = { question: 'Create the key?' }
3295
+ expect(pendingAnsweredByPrompt('Ship it', [choiceQuestion]).map((entry) => entry.question)).toEqual([
3296
+ 'Ship it?',
3297
+ ])
3298
+ expect(pendingAnsweredByPrompt('done', [freeText])).toEqual([freeText])
3299
+ expect(pendingAnsweredByPrompt('done', [choiceQuestion, freeText])).toEqual([])
3300
+ expect(pendingAnsweredByPrompt('keep going on the billing work', [freeText])).toEqual([])
3301
+ })
3302
+
3303
+ it('does not push a conversation-answered question after a newer one is registered', async () => {
3304
+ const h = harness([])
3305
+ writeSessionState('stale-then-new', h.env, { last_prompt_at: AWAY })
3306
+ registerQuestion('stale-then-new', h.env, { question: 'Create the Apple IAP key?' })
3307
+
3308
+ await hookRunCommand(
3309
+ h.deps,
3310
+ 'user-prompt-submit',
3311
+ stdin({ session_id: 'stale-then-new', prompt: 'done' }),
3312
+ )
3313
+ expect(readSessionState('stale-then-new', h.env).pending).toBeUndefined()
3314
+
3315
+ registerQuestion('stale-then-new', h.env, { question: 'Which ASN endpoint?' })
3316
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'stale-then-new' }))
3317
+
3318
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
3319
+ expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['Which ASN endpoint?'])
3320
+ })
3321
+
3322
+ it('reserves a close-fence budget when the owner deadline is nearly now', async () => {
3323
+ const h = harness([])
3324
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
3325
+ mkdirSync(dir, { recursive: true })
3326
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
3327
+ writeSessionState('near-zero', h.env, {
3328
+ last_prompt_at: AWAY,
3329
+ pending: [{
3330
+ question: 'Ship it?',
3331
+ asked_at: NOW - 60_000,
3332
+ request_id: 'req_live',
3333
+ collapse_key: 'collapse-live',
3334
+ device_ids: ['dev_iphone'],
3335
+ reply_deadline_at: NOW + 4_001,
3336
+ owner_deadline_at: NOW + 4_001,
3337
+ }],
3338
+ })
3339
+
3340
+ await hookRunCommand(
3341
+ { ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) },
3342
+ 'stop',
3343
+ stdin({ session_id: 'near-zero', cwd: h.deps.cwd }),
3344
+ )
3345
+
3346
+ expect(h.recorder.closed).toContain('req_live')
3347
+ expect(
3348
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3349
+ ).toHaveLength(0)
3350
+ expect(h.deps.now?.()).toBeLessThan(NOW + 10_000)
3351
+ const records = readLogRecords(h.env, { event: ['hook.retirement'] }).records
3352
+ expect(records.some((record) => record.data?.['proven'] === true)).toBe(true)
3353
+ })
3354
+ })
3355
+
2733
3356
  describe('session-end hook', () => {
2734
3357
  it('drops local state without touching the network and records the lifecycle', async () => {
2735
3358
  const h = harness()
@@ -2818,7 +3441,7 @@ describe('session-end hook', () => {
2818
3441
  'the real Codex hook did not reach contended diagnostics before its deadline',
2819
3442
  )
2820
3443
 
2821
- // Reaching the second bakery ticket proves main.ts preAction did not write
3444
+ // Reaching the second bakery ticket proves program.ts preAction did not write
2822
3445
  // first. Every durable cleanup must already be visible while diagnostics
2823
3446
  // are still blocked behind the live holder.
2824
3447
  expect(existsSync(stateFile)).toBe(false)
@@ -2997,7 +3620,7 @@ describe('telling concurrent agents apart', () => {
2997
3620
  'claude-code',
2998
3621
  )
2999
3622
 
3000
- const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3623
+ const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
3001
3624
  expect(question?.draft.source).toEqual({
3002
3625
  session_id: 'session-branch-transition',
3003
3626
  session_label: 'Branch transition',
@@ -3019,15 +3642,10 @@ describe('telling concurrent agents apart', () => {
3019
3642
  'claude-code',
3020
3643
  )
3021
3644
 
3022
- const retirement = h.recorder.submitted.find(
3023
- (entry) => entry.draft.event === 'question_retired',
3024
- )
3025
- expect(retirement?.draft.source).toEqual({
3026
- session_id: 'session-branch-transition',
3027
- session_label: 'Branch transition',
3028
- harness: 'claude-code',
3029
- branch: 'branch-a',
3030
- })
3645
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3646
+ expect(
3647
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3648
+ ).toHaveLength(0)
3031
3649
  })
3032
3650
 
3033
3651
  it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
@@ -3057,7 +3675,7 @@ describe('telling concurrent agents apart', () => {
3057
3675
  'claude-code',
3058
3676
  )
3059
3677
 
3060
- const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3678
+ const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
3061
3679
  expect(question?.draft.source).toEqual({
3062
3680
  session_id: 'session-detached',
3063
3681
  session_label: 'Detached review',
@@ -3093,7 +3711,7 @@ describe('telling concurrent agents apart', () => {
3093
3711
  'claude-code',
3094
3712
  )
3095
3713
  const question = h.recorder.submitted.find(
3096
- (entry) => entry.draft.event === 'agent_question',
3714
+ (entry) => isQuestionSubmit(entry),
3097
3715
  )
3098
3716
  expect(question?.draft.source).toEqual({
3099
3717
  session_id: 'session-worktree-transition',
@@ -3115,15 +3733,10 @@ describe('telling concurrent agents apart', () => {
3115
3733
  }),
3116
3734
  'claude-code',
3117
3735
  )
3118
- const retirement = h.recorder.submitted.find(
3119
- (entry) => entry.draft.event === 'question_retired',
3120
- )
3121
- expect(retirement?.draft.source).toEqual({
3122
- session_id: 'session-worktree-transition',
3123
- session_label: 'Worktree transition',
3124
- harness: 'claude-code',
3125
- branch: 'main',
3126
- })
3736
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3737
+ expect(
3738
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3739
+ ).toHaveLength(0)
3127
3740
  } finally {
3128
3741
  runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
3129
3742
  }
@@ -3179,12 +3792,10 @@ describe('telling concurrent agents apart', () => {
3179
3792
  stdin({ session_id: 'sess-abc', stop_hook_active: true }),
3180
3793
  )
3181
3794
 
3182
- const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
3183
- expect(retirement?.draft.source).toEqual({
3184
- session_id: 'sess-abc',
3185
- session_label: 'Semantic session',
3186
- harness: 'claude-code',
3187
- })
3795
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3796
+ expect(
3797
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3798
+ ).toHaveLength(0)
3188
3799
  })
3189
3800
 
3190
3801
  it('preserves a human session label without displaying the opaque id', async () => {
@@ -3362,7 +3973,7 @@ describe('durable state writes', () => {
3362
3973
  expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
3363
3974
  })
3364
3975
 
3365
- it('persists each successful retirement before a later corrupt entry interrupts the drain', async () => {
3976
+ it('persists each successful retirement before a later close failure interrupts the drain', async () => {
3366
3977
  const h = harness()
3367
3978
  writeSessionState('partial-drain', h.env, {
3368
3979
  retiring: [
@@ -3376,24 +3987,30 @@ describe('durable state writes', () => {
3376
3987
  {
3377
3988
  request_id: 'req_bad',
3378
3989
  collapse_key: 'collapse-bad',
3379
- device_ids: [],
3990
+ device_ids: ['dev_iphone'],
3380
3991
  question: 'Second?',
3381
3992
  state: 'expired',
3382
3993
  },
3383
3994
  ],
3384
3995
  })
3996
+ const base = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer x')
3385
3997
  const ctx = {
3386
- client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'),
3998
+ client: {
3999
+ ...base,
4000
+ closeReplies: async (requestId: string) => {
4001
+ if (requestId === 'req_bad') throw new Error('offline')
4002
+ return base.closeReplies(requestId)
4003
+ },
4004
+ },
3387
4005
  config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
3388
4006
  }
3389
4007
 
3390
- await expect(drainRetirements(ctx, 'partial-drain', h.env)).rejects.toThrow(
3391
- /missing its device identifiers/,
3392
- )
4008
+ await drainRetirements(ctx, 'partial-drain', h.env)
3393
4009
 
3394
4010
  expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
3395
4011
  'req_bad',
3396
4012
  ])
4013
+ expect(h.recorder.closed).toEqual(['req_good'])
3397
4014
  })
3398
4015
  })
3399
4016
 
@@ -3716,14 +4333,10 @@ describe('Claude Code Stop wake route', () => {
3716
4333
  expect(wake.sent).toHaveLength(1)
3717
4334
  expect(wake.resumed).toEqual([])
3718
4335
  expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
3719
- // Settled, not merely dropped: the answered question is retired truthfully.
4336
+ expect(h.recorder.closed).toContain('req_existing')
3720
4337
  expect(
3721
- h.recorder.submitted.find(
3722
- (entry) =>
3723
- entry.draft.event === 'question_retired' &&
3724
- entry.draft.lifecycle?.retires_request_id === 'req_existing',
3725
- )?.draft.lifecycle?.state,
3726
- ).toBe('answered')
4338
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
4339
+ ).toHaveLength(0)
3727
4340
  })
3728
4341
 
3729
4342
  it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
@@ -3751,7 +4364,7 @@ describe('Claude Code Stop wake route', () => {
3751
4364
  'claude-code',
3752
4365
  )
3753
4366
 
3754
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
4367
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual(
3755
4368
  [],
3756
4369
  )
3757
4370
  expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')