@raidiant/notifai 8.0.0 → 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 (145) hide show
  1. package/CHANGELOG.md +41 -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-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. 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[] => {
@@ -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,
@@ -736,7 +750,7 @@ describe('what the hook leaves behind', () => {
736
750
  )
737
751
 
738
752
  const raw = readFileSync(activeLogPath(h.env), 'utf8')
739
- expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
753
+ expect(raw).not.toContain('Allow exactly once')
740
754
  const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
741
755
  expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
742
756
  'queued',
@@ -867,7 +881,7 @@ describe('terminal-first grace window', () => {
867
881
 
868
882
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
869
883
 
870
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
884
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
871
885
  expect(submittedAt).toBe(NOW)
872
886
  })
873
887
 
@@ -900,7 +914,7 @@ describe('terminal-first grace window', () => {
900
914
 
901
915
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
902
916
 
903
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
917
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
904
918
  expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
905
919
  })
906
920
 
@@ -929,7 +943,7 @@ describe('terminal-first grace window', () => {
929
943
  expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
930
944
  expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
931
945
  expect(
932
- h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
946
+ h.recorder.submitted.find((entry) => isQuestionSubmit(entry))?.draft.reply
933
947
  ?.expires_in_seconds,
934
948
  ).toBeGreaterThanOrEqual(60)
935
949
  })
@@ -1038,7 +1052,7 @@ describe('nagging guards', () => {
1038
1052
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
1039
1053
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
1040
1054
 
1041
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
1055
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
1042
1056
  })
1043
1057
 
1044
1058
  it('respects the harness recursion guard', async () => {
@@ -1066,7 +1080,7 @@ describe('nagging guards', () => {
1066
1080
 
1067
1081
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
1068
1082
 
1069
- const questions = h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')
1083
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
1070
1084
  expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
1071
1085
  'First question?',
1072
1086
  'Follow-up question?',
@@ -1123,7 +1137,7 @@ describe('late answer collection', () => {
1123
1137
 
1124
1138
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
1125
1139
 
1126
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
1140
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1127
1141
  expect(h.recorder.closed).toContain('req_live')
1128
1142
  expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
1129
1143
  expect(h.io.outLines).toHaveLength(1)
@@ -1170,14 +1184,10 @@ describe('late answer collection', () => {
1170
1184
  stdin({ session_id: 'late-prompt', stop_hook_active: true }),
1171
1185
  )
1172
1186
 
1173
- const retirement = h.recorder.submitted.find(
1174
- (entry) => entry.draft.event === 'question_retired',
1175
- )?.draft
1176
- expect(retirement?.lifecycle).toMatchObject({
1177
- state: 'answered',
1178
- retires_request_id: 'req_live',
1179
- })
1180
- 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)
1181
1191
  expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
1182
1192
  expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
1183
1193
  })
@@ -1191,10 +1201,10 @@ describe('OpenCode answer continuation', () => {
1191
1201
 
1192
1202
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
1193
1203
 
1194
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
1204
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1195
1205
  expect(h.recorder.closed).toEqual([])
1196
1206
  expect(
1197
- h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
1207
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
1198
1208
  ).toHaveLength(0)
1199
1209
  expect(readSessionState('open1', h.env).pending).toBeUndefined()
1200
1210
  expect(h.io.outLines).toEqual([])
@@ -1268,7 +1278,7 @@ describe('the waiter owning one question to the end', () => {
1268
1278
  return {
1269
1279
  ...client,
1270
1280
  submit: async (body: SubmitNotificationRequestT) => {
1271
- if (body.draft.event !== 'agent_question') return client.submit(body, 0)
1281
+ if (body.draft.reply === undefined) return client.submit(body, 0)
1272
1282
  acceptedRequestId = body.request_id
1273
1283
  h.recorder.submitted.push(body)
1274
1284
  h.recorder.receipts.push(body.request_id!)
@@ -1496,7 +1506,7 @@ describe('the waiter owning one question to the end', () => {
1496
1506
 
1497
1507
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
1498
1508
 
1499
- expect(h.deps.now?.()).toBe(NOW + 60_000)
1509
+ expect(h.deps.now?.()).toBe(NOW + 57_000)
1500
1510
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1501
1511
  expect(readSessionState('deadline', h.env).pending).toBeUndefined()
1502
1512
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
@@ -1512,7 +1522,7 @@ describe('the waiter owning one question to the end', () => {
1512
1522
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
1513
1523
 
1514
1524
  const windows = h.recorder.submitted
1515
- .filter((entry) => entry.draft.event === 'agent_question')
1525
+ .filter((entry) => isQuestionSubmit(entry))
1516
1526
  .map((entry) => entry.draft.reply?.expires_in_seconds)
1517
1527
  // How long the user may answer does not shrink because this owner spent
1518
1528
  // part of its own budget getting the question out. Both questions stay
@@ -1552,7 +1562,116 @@ describe('the waiter owning one question to the end', () => {
1552
1562
 
1553
1563
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
1554
1564
  expect(readSessionState('successor', h.env).pending).toBeUndefined()
1555
- 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)
1556
1675
  })
1557
1676
  })
1558
1677
 
@@ -1588,14 +1707,9 @@ describe('Cursor stop output', () => {
1588
1707
  * questions piling up on a phone teach the user to ignore the surface.
1589
1708
  */
1590
1709
  describe('question queueing and retirement debt', () => {
1591
- /** The lifecycle state of each retirement push the recorder captured. */
1592
- function retirements(h: Harness): { state: unknown; retires: unknown }[] {
1593
- return h.recorder.submitted
1594
- .filter((s) => s.draft.event === 'question_retired')
1595
- .map((s) => ({
1596
- state: s.draft.lifecycle?.state,
1597
- retires: s.draft.lifecycle?.retires_request_id,
1598
- }))
1710
+ /** Request ids whose close fence proved retirement. */
1711
+ function retirements(h: Harness): string[] {
1712
+ return h.recorder.closed
1599
1713
  }
1600
1714
 
1601
1715
  function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
@@ -1635,13 +1749,17 @@ describe('question queueing and retirement debt', () => {
1635
1749
 
1636
1750
  // Offline when the user returns: the wipe parks the retirement, the
1637
1751
  // drain fails, and it must not forget what it was for.
1638
- h.recorder.failSubmits = true
1639
- 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
+ )
1640
1758
  expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
1641
1759
 
1642
- h.recorder.failSubmits = false
1760
+ h.recorder.failCloses = false
1643
1761
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
1644
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1762
+ expect(retirements(h)).toContain(first)
1645
1763
  expect(readSessionState('sup2', h.env).retiring).toEqual([])
1646
1764
  })
1647
1765
 
@@ -1661,16 +1779,22 @@ describe('question queueing and retirement debt', () => {
1661
1779
  const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
1662
1780
  mkdirSync(configDir, { recursive: true })
1663
1781
  writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
1664
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
1665
-
1666
- const retirement = h.recorder.submitted.find(
1667
- (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' }),
1668
1787
  )
1669
- expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
1670
- expect(retirement?.draft.targets).toEqual({
1671
- mode: 'selected',
1672
- device_ids: ['dev_iphone', 'dev_mac'],
1673
- })
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)
1674
1798
  })
1675
1799
 
1676
1800
  it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
@@ -1681,14 +1805,18 @@ describe('question queueing and retirement debt', () => {
1681
1805
  seedLive(h, 'sup3')
1682
1806
  const first = 'req_live'
1683
1807
 
1684
- h.recorder.failSubmits = true
1685
- 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
+ )
1686
1814
  expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
1687
- h.recorder.failSubmits = false
1815
+ h.recorder.failCloses = false
1688
1816
 
1689
1817
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
1690
1818
 
1691
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1819
+ expect(retirements(h)).toContain(first)
1692
1820
  expect(readSessionState('sup3', h.env).retiring).toEqual([])
1693
1821
  })
1694
1822
 
@@ -1701,13 +1829,21 @@ describe('question queueing and retirement debt', () => {
1701
1829
 
1702
1830
  // The first return is offline: the wipe parks, the drain fails, and the
1703
1831
  // next prompt must still find the debt.
1704
- h.recorder.failSubmits = true
1705
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
1706
- 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
1707
1839
 
1708
- 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
+ )
1709
1845
 
1710
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1846
+ expect(retirements(h)).toContain(first)
1711
1847
  expect(readSessionState('sup4', h.env).retiring).toEqual([])
1712
1848
  })
1713
1849
 
@@ -1741,7 +1877,7 @@ describe('several questions in flight', () => {
1741
1877
 
1742
1878
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
1743
1879
 
1744
- const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1880
+ const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
1745
1881
  expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
1746
1882
  // Each is its own notification with its own collapse key — one ask never
1747
1883
  // stands in for, or replaces, another.
@@ -1785,7 +1921,7 @@ describe('several questions in flight', () => {
1785
1921
  'codex',
1786
1922
  )
1787
1923
 
1788
- expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
1924
+ expect(h.recorder.submitted.filter((s) => isQuestionSubmit(s))).toHaveLength(1)
1789
1925
  expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
1790
1926
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1791
1927
  expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
@@ -1819,7 +1955,7 @@ describe('several questions in flight', () => {
1819
1955
 
1820
1956
  // Submit recorded the durable id before any wait; a 500 must not erase it
1821
1957
  // mid-flight, and recovery must still surface the late answer.
1822
- const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1958
+ const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
1823
1959
  expect(questions).toHaveLength(1)
1824
1960
  expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1825
1961
  expect(polls).toBeGreaterThanOrEqual(3)
@@ -1959,13 +2095,10 @@ describe('several questions in flight', () => {
1959
2095
  )
1960
2096
  expect(h.io.outLines).toEqual([])
1961
2097
  expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
2098
+ expect(h.recorder.closed).toContain('req_existing')
1962
2099
  expect(
1963
- h.recorder.submitted.find(
1964
- (entry) =>
1965
- entry.draft.event === 'question_retired' &&
1966
- entry.draft.lifecycle?.retires_request_id === 'req_existing',
1967
- )?.draft.lifecycle?.state,
1968
- ).toBe('answered')
2100
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
2101
+ ).toHaveLength(0)
1969
2102
  })
1970
2103
 
1971
2104
  it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
@@ -2155,19 +2288,14 @@ describe('several questions in flight', () => {
2155
2288
  expect(followup.reason).toContain('Staging')
2156
2289
  expect(readSessionState('multi3', h.env).pending).toBeUndefined()
2157
2290
  expect(
2158
- h.recorder.submitted.filter((s) => s.draft.event === 'agent_question'),
2291
+ h.recorder.submitted.filter((s) => isQuestionSubmit(s)),
2159
2292
  ).toHaveLength(2)
2160
2293
  })
2161
2294
  })
2162
2295
 
2163
2296
  describe('a question that outlives its session', () => {
2164
- function retirements(h: Harness): { state: unknown; retires: unknown }[] {
2165
- return h.recorder.submitted
2166
- .filter((s) => s.draft.event === 'question_retired')
2167
- .map((s) => ({
2168
- state: s.draft.lifecycle?.state,
2169
- retires: s.draft.lifecycle?.retires_request_id,
2170
- }))
2297
+ function retirements(h: Harness): string[] {
2298
+ return h.recorder.closed
2171
2299
  }
2172
2300
 
2173
2301
  /** State a still-owned request can have when SessionEnd races its Stop. */
@@ -2198,7 +2326,7 @@ describe('a question that outlives its session', () => {
2198
2326
  // A different session's next hook holds a client and inherits the debt.
2199
2327
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
2200
2328
  expect(h.recorder.closed).toContain(first)
2201
- expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
2329
+ expect(retirements(h)).toContain(first)
2202
2330
  })
2203
2331
 
2204
2332
  it('carries parked retirements across SessionEnd too', async () => {
@@ -2207,13 +2335,17 @@ describe('a question that outlives its session', () => {
2207
2335
 
2208
2336
  // Died while offline: the user came back, the wipe parked the
2209
2337
  // retirement, and the drain could not send it.
2210
- h.recorder.failSubmits = true
2211
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
2212
- 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
2213
2345
 
2214
2346
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
2215
2347
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
2216
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
2348
+ expect(retirements(h)).toContain(first)
2217
2349
  })
2218
2350
 
2219
2351
  it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
@@ -2222,16 +2354,16 @@ describe('a question that outlives its session', () => {
2222
2354
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
2223
2355
 
2224
2356
  // Still offline: the queue must survive a failed drain.
2225
- h.recorder.failSubmits = true
2357
+ h.recorder.failCloses = true
2226
2358
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2227
- h.recorder.failSubmits = false
2359
+ h.recorder.failCloses = false
2228
2360
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2229
- expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
2361
+ expect(retirements(h)).toContain(first)
2230
2362
 
2231
2363
  // And a second drain does not send it twice.
2232
- const count = retirements(h).filter((r) => r.retires === first).length
2364
+ const count = retirements(h).filter((id) => id === first).length
2233
2365
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2234
- expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
2366
+ expect(retirements(h).filter((id) => id === first)).toHaveLength(count)
2235
2367
  })
2236
2368
 
2237
2369
  it('gives up on an orphan that outlived the longest answerable window', async () => {
@@ -2498,7 +2630,347 @@ describe('ask registration', () => {
2498
2630
  })
2499
2631
  })
2500
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
+
2501
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
+
2502
2974
  it('records presence', async () => {
2503
2975
  const h = harness()
2504
2976
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
@@ -2564,7 +3036,7 @@ describe('user-prompt-submit hook', () => {
2564
3036
  expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
2565
3037
  })
2566
3038
 
2567
- 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 () => {
2568
3040
  const h = harness([])
2569
3041
  const live = {
2570
3042
  question: 'Ship it?',
@@ -2575,10 +3047,6 @@ describe('user-prompt-submit hook', () => {
2575
3047
  }
2576
3048
  writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
2577
3049
 
2578
- // A second plain `ask` resolves through the project pointer and joins the
2579
- // queue behind the live question — it must not touch it. The next prompt
2580
- // then resets presence before another Stop can run — the transition that
2581
- // used to erase the retirement debt.
2582
3050
  registerQuestion('transition', h.env, { question: 'Deploy it?' })
2583
3051
  const queued = readSessionState('transition', h.env)
2584
3052
  expect(queued.retiring ?? []).toEqual([])
@@ -2587,35 +3055,22 @@ describe('user-prompt-submit hook', () => {
2587
3055
  await hookRunCommand(
2588
3056
  h.deps,
2589
3057
  'user-prompt-submit',
2590
- 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
+ }),
2591
3063
  'claude-code',
2592
3064
  )
2593
3065
 
2594
- // The delivered question retires truthfully. The never-delivered second
2595
- // one must remain queued: if Codex skipped Stop, erasing it here loses the
2596
- // only copy before it ever reached a device.
2597
- const retirement = h.recorder.submitted.find(
2598
- (submission) => submission.draft.event === 'question_retired',
2599
- )?.draft
2600
- expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
2601
- expect(retirement?.lifecycle).toEqual({
2602
- tier: 'done',
2603
- state: 'answered_elsewhere',
2604
- retires_request_id: live.request_id,
2605
- })
2606
- expect(retirement?.targets).toEqual({
2607
- mode: 'selected',
2608
- device_ids: ['dev_iphone', 'dev_mac'],
2609
- })
3066
+ expect(h.recorder.closed).toEqual([])
2610
3067
  expect(
2611
- h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
2612
- ).toHaveLength(1)
3068
+ h.recorder.submitted.filter((s) => isRetirementSubmit(s)),
3069
+ ).toHaveLength(0)
2613
3070
  expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
3071
+ 'Ship it?',
2614
3072
  'Deploy it?',
2615
3073
  ])
2616
-
2617
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
2618
- expect(readSessionState('transition', h.env).pending).toBeUndefined()
2619
3074
  })
2620
3075
 
2621
3076
  it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
@@ -2625,14 +3080,14 @@ describe('user-prompt-submit hook', () => {
2625
3080
 
2626
3081
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
2627
3082
  expect(readSessionState('s11', h.env).pending).toBeUndefined()
2628
- const retirementCount = h.recorder.submitted.filter(
2629
- (entry) => entry.draft.event === 'question_retired',
2630
- ).length
3083
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2631
3084
 
2632
3085
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
2633
3086
 
2634
3087
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2635
- 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)
2636
3091
  })
2637
3092
 
2638
3093
  it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
@@ -2651,16 +3106,16 @@ describe('user-prompt-submit hook', () => {
2651
3106
  }],
2652
3107
  })
2653
3108
 
2654
- 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
+ )
2655
3114
 
2656
- const retirement = h.recorder.submitted.at(-1)?.draft
2657
- expect(retirement?.lifecycle).toEqual({
2658
- tier: 'done',
2659
- state: 'answered_elsewhere',
2660
- // The history-entry correlation id: companions key entries by request
2661
- // id and never persisted the collapse key.
2662
- retires_request_id: 'req_live',
2663
- })
3115
+ expect(h.recorder.closed).toContain('req_live')
3116
+ expect(
3117
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3118
+ ).toHaveLength(0)
2664
3119
  })
2665
3120
 
2666
3121
  it('retires as done/answered when the answer came from a device', async () => {
@@ -2676,12 +3131,71 @@ describe('user-prompt-submit hook', () => {
2676
3131
  stdin({ session_id: 's16', stop_hook_active: true }),
2677
3132
  )
2678
3133
 
2679
- const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
2680
- expect(retirement?.draft.lifecycle).toEqual({
2681
- tier: 'done',
2682
- state: 'answered',
2683
- 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
+ }],
2684
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')
2685
3199
  })
2686
3200
 
2687
3201
  it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
@@ -2762,6 +3276,83 @@ describe('user-prompt-submit hook', () => {
2762
3276
  })
2763
3277
  })
2764
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
+
2765
3356
  describe('session-end hook', () => {
2766
3357
  it('drops local state without touching the network and records the lifecycle', async () => {
2767
3358
  const h = harness()
@@ -3029,7 +3620,7 @@ describe('telling concurrent agents apart', () => {
3029
3620
  'claude-code',
3030
3621
  )
3031
3622
 
3032
- const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3623
+ const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
3033
3624
  expect(question?.draft.source).toEqual({
3034
3625
  session_id: 'session-branch-transition',
3035
3626
  session_label: 'Branch transition',
@@ -3051,15 +3642,10 @@ describe('telling concurrent agents apart', () => {
3051
3642
  'claude-code',
3052
3643
  )
3053
3644
 
3054
- const retirement = h.recorder.submitted.find(
3055
- (entry) => entry.draft.event === 'question_retired',
3056
- )
3057
- expect(retirement?.draft.source).toEqual({
3058
- session_id: 'session-branch-transition',
3059
- session_label: 'Branch transition',
3060
- harness: 'claude-code',
3061
- branch: 'branch-a',
3062
- })
3645
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3646
+ expect(
3647
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3648
+ ).toHaveLength(0)
3063
3649
  })
3064
3650
 
3065
3651
  it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
@@ -3089,7 +3675,7 @@ describe('telling concurrent agents apart', () => {
3089
3675
  'claude-code',
3090
3676
  )
3091
3677
 
3092
- const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3678
+ const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
3093
3679
  expect(question?.draft.source).toEqual({
3094
3680
  session_id: 'session-detached',
3095
3681
  session_label: 'Detached review',
@@ -3125,7 +3711,7 @@ describe('telling concurrent agents apart', () => {
3125
3711
  'claude-code',
3126
3712
  )
3127
3713
  const question = h.recorder.submitted.find(
3128
- (entry) => entry.draft.event === 'agent_question',
3714
+ (entry) => isQuestionSubmit(entry),
3129
3715
  )
3130
3716
  expect(question?.draft.source).toEqual({
3131
3717
  session_id: 'session-worktree-transition',
@@ -3147,15 +3733,10 @@ describe('telling concurrent agents apart', () => {
3147
3733
  }),
3148
3734
  'claude-code',
3149
3735
  )
3150
- const retirement = h.recorder.submitted.find(
3151
- (entry) => entry.draft.event === 'question_retired',
3152
- )
3153
- expect(retirement?.draft.source).toEqual({
3154
- session_id: 'session-worktree-transition',
3155
- session_label: 'Worktree transition',
3156
- harness: 'claude-code',
3157
- branch: 'main',
3158
- })
3736
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3737
+ expect(
3738
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3739
+ ).toHaveLength(0)
3159
3740
  } finally {
3160
3741
  runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
3161
3742
  }
@@ -3211,12 +3792,10 @@ describe('telling concurrent agents apart', () => {
3211
3792
  stdin({ session_id: 'sess-abc', stop_hook_active: true }),
3212
3793
  )
3213
3794
 
3214
- const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
3215
- expect(retirement?.draft.source).toEqual({
3216
- session_id: 'sess-abc',
3217
- session_label: 'Semantic session',
3218
- harness: 'claude-code',
3219
- })
3795
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3796
+ expect(
3797
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3798
+ ).toHaveLength(0)
3220
3799
  })
3221
3800
 
3222
3801
  it('preserves a human session label without displaying the opaque id', async () => {
@@ -3394,7 +3973,7 @@ describe('durable state writes', () => {
3394
3973
  expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
3395
3974
  })
3396
3975
 
3397
- 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 () => {
3398
3977
  const h = harness()
3399
3978
  writeSessionState('partial-drain', h.env, {
3400
3979
  retiring: [
@@ -3408,24 +3987,30 @@ describe('durable state writes', () => {
3408
3987
  {
3409
3988
  request_id: 'req_bad',
3410
3989
  collapse_key: 'collapse-bad',
3411
- device_ids: [],
3990
+ device_ids: ['dev_iphone'],
3412
3991
  question: 'Second?',
3413
3992
  state: 'expired',
3414
3993
  },
3415
3994
  ],
3416
3995
  })
3996
+ const base = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer x')
3417
3997
  const ctx = {
3418
- 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
+ },
3419
4005
  config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
3420
4006
  }
3421
4007
 
3422
- await expect(drainRetirements(ctx, 'partial-drain', h.env)).rejects.toThrow(
3423
- /missing its device identifiers/,
3424
- )
4008
+ await drainRetirements(ctx, 'partial-drain', h.env)
3425
4009
 
3426
4010
  expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
3427
4011
  'req_bad',
3428
4012
  ])
4013
+ expect(h.recorder.closed).toEqual(['req_good'])
3429
4014
  })
3430
4015
  })
3431
4016
 
@@ -3748,14 +4333,10 @@ describe('Claude Code Stop wake route', () => {
3748
4333
  expect(wake.sent).toHaveLength(1)
3749
4334
  expect(wake.resumed).toEqual([])
3750
4335
  expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
3751
- // Settled, not merely dropped: the answered question is retired truthfully.
4336
+ expect(h.recorder.closed).toContain('req_existing')
3752
4337
  expect(
3753
- h.recorder.submitted.find(
3754
- (entry) =>
3755
- entry.draft.event === 'question_retired' &&
3756
- entry.draft.lifecycle?.retires_request_id === 'req_existing',
3757
- )?.draft.lifecycle?.state,
3758
- ).toBe('answered')
4338
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
4339
+ ).toHaveLength(0)
3759
4340
  })
3760
4341
 
3761
4342
  it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
@@ -3783,7 +4364,7 @@ describe('Claude Code Stop wake route', () => {
3783
4364
  'claude-code',
3784
4365
  )
3785
4366
 
3786
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
4367
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual(
3787
4368
  [],
3788
4369
  )
3789
4370
  expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')