@raidiant/notifai 9.2.2 → 10.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 (149) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/client.d.ts +3 -1
  6. package/dist/client.js +2 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.js +9 -0
  9. package/dist/codex-wake.js.map +1 -1
  10. package/dist/commands-agent-sessions.d.ts +6 -0
  11. package/dist/commands-agent-sessions.js +51 -0
  12. package/dist/commands-agent-sessions.js.map +1 -0
  13. package/dist/commands-config.js +11 -4
  14. package/dist/commands-config.js.map +1 -1
  15. package/dist/commands-core.d.ts +1 -1
  16. package/dist/commands-doctor.js +83 -6
  17. package/dist/commands-doctor.js.map +1 -1
  18. package/dist/commands-harness-context.d.ts +15 -2
  19. package/dist/commands-harness-context.js +32 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hook-shape.d.ts +4 -3
  22. package/dist/commands-hook-shape.js +19 -10
  23. package/dist/commands-hook-shape.js.map +1 -1
  24. package/dist/commands-hooks.d.ts +4 -3
  25. package/dist/commands-hooks.js +80 -23
  26. package/dist/commands-hooks.js.map +1 -1
  27. package/dist/commands-init.js +12 -1
  28. package/dist/commands-init.js.map +1 -1
  29. package/dist/commands-project.d.ts +4 -0
  30. package/dist/commands-project.js +41 -0
  31. package/dist/commands-project.js.map +1 -0
  32. package/dist/commands-send-support.js +0 -4
  33. package/dist/commands-send-support.js.map +1 -1
  34. package/dist/commands-send.js +12 -6
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +58 -0
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-sounds.d.ts +12 -0
  39. package/dist/commands-sounds.js +35 -0
  40. package/dist/commands-sounds.js.map +1 -0
  41. package/dist/commands.d.ts +3 -0
  42. package/dist/commands.js +3 -0
  43. package/dist/commands.js.map +1 -1
  44. package/dist/config-schema.js +7 -4
  45. package/dist/config-schema.js.map +1 -1
  46. package/dist/config.d.ts +6 -7
  47. package/dist/config.js +9 -9
  48. package/dist/config.js.map +1 -1
  49. package/dist/credentials.js +4 -1
  50. package/dist/credentials.js.map +1 -1
  51. package/dist/guidance-content.d.ts +3 -0
  52. package/dist/guidance-content.js +20 -0
  53. package/dist/guidance-content.js.map +1 -1
  54. package/dist/guidance-render.js +3 -3
  55. package/dist/guidance-render.js.map +1 -1
  56. package/dist/harnesses.d.ts +49 -7
  57. package/dist/harnesses.js +90 -24
  58. package/dist/harnesses.js.map +1 -1
  59. package/dist/hooks.d.ts +28 -52
  60. package/dist/hooks.js +393 -152
  61. package/dist/hooks.js.map +1 -1
  62. package/dist/install-hooks.d.ts +17 -39
  63. package/dist/install-hooks.js +21 -41
  64. package/dist/install-hooks.js.map +1 -1
  65. package/dist/invocation-context.d.ts +2 -2
  66. package/dist/orca-session-title.d.ts +2 -2
  67. package/dist/orca-session-title.js +3 -4
  68. package/dist/orca-session-title.js.map +1 -1
  69. package/dist/program.d.ts +6 -1
  70. package/dist/program.js +46 -5
  71. package/dist/program.js.map +1 -1
  72. package/dist/project-enablement.d.ts +15 -0
  73. package/dist/project-enablement.js +87 -0
  74. package/dist/project-enablement.js.map +1 -0
  75. package/dist/question-timing.d.ts +46 -0
  76. package/dist/question-timing.js +48 -0
  77. package/dist/question-timing.js.map +1 -0
  78. package/dist/readiness.d.ts +7 -0
  79. package/dist/readiness.js.map +1 -1
  80. package/dist/send.d.ts +2 -0
  81. package/dist/send.js +17 -15
  82. package/dist/send.js.map +1 -1
  83. package/dist/session-activation.d.ts +1 -1
  84. package/dist/session-activation.js +3 -4
  85. package/dist/session-activation.js.map +1 -1
  86. package/dist/session-labels.d.ts +18 -2
  87. package/dist/session-labels.js +79 -8
  88. package/dist/session-labels.js.map +1 -1
  89. package/dist/sound-ref.d.ts +23 -0
  90. package/dist/sound-ref.js +28 -0
  91. package/dist/sound-ref.js.map +1 -0
  92. package/package.json +3 -3
  93. package/src/claude-wake.test.ts +1 -0
  94. package/src/claude-wake.ts +10 -0
  95. package/src/client.test.ts +12 -0
  96. package/src/client.ts +8 -0
  97. package/src/codex-wake.test.ts +1 -0
  98. package/src/codex-wake.ts +9 -0
  99. package/src/commands-agent-sessions.ts +72 -0
  100. package/src/commands-config.ts +10 -4
  101. package/src/commands-core.ts +1 -1
  102. package/src/commands-doctor.ts +96 -8
  103. package/src/commands-harness-context.ts +48 -2
  104. package/src/commands-hook-shape.ts +26 -10
  105. package/src/commands-hooks.ts +95 -31
  106. package/src/commands-init.ts +11 -1
  107. package/src/commands-project.ts +47 -0
  108. package/src/commands-send-support.ts +0 -4
  109. package/src/commands-send.ts +10 -5
  110. package/src/commands-skill.test.ts +44 -0
  111. package/src/commands-skill.ts +57 -0
  112. package/src/commands-sounds.test.ts +106 -0
  113. package/src/commands-sounds.ts +49 -0
  114. package/src/commands.test.ts +386 -23
  115. package/src/commands.ts +10 -0
  116. package/src/config-schema.test.ts +1 -0
  117. package/src/config-schema.ts +7 -4
  118. package/src/config.test.ts +44 -23
  119. package/src/config.ts +13 -15
  120. package/src/credentials.test.ts +20 -0
  121. package/src/credentials.ts +4 -1
  122. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  123. package/src/guidance-content.ts +22 -0
  124. package/src/guidance-render.ts +3 -3
  125. package/src/harness-integration.test.ts +212 -0
  126. package/src/harnesses.test.ts +107 -8
  127. package/src/harnesses.ts +111 -30
  128. package/src/hook-adapter.test.ts +27 -8
  129. package/src/hooks.test.ts +812 -83
  130. package/src/hooks.ts +529 -185
  131. package/src/install-hooks.test.ts +13 -16
  132. package/src/install-hooks.ts +40 -58
  133. package/src/invocation-context.ts +2 -2
  134. package/src/orca-session-title.test.ts +8 -49
  135. package/src/orca-session-title.ts +3 -5
  136. package/src/program.test.ts +45 -0
  137. package/src/program.ts +60 -4
  138. package/src/project-enablement.test.ts +55 -0
  139. package/src/project-enablement.ts +99 -0
  140. package/src/question-timing.ts +56 -0
  141. package/src/readiness.ts +8 -1
  142. package/src/send.ts +18 -20
  143. package/src/session-activation.ts +3 -4
  144. package/src/session-labels.test.ts +58 -9
  145. package/src/session-labels.ts +95 -12
  146. package/src/skill.test.ts +14 -3
  147. package/src/sound-ref.test.ts +31 -0
  148. package/src/sound-ref.ts +34 -0
  149. package/src/ui/config-view.test.ts +1 -0
package/src/hooks.test.ts CHANGED
@@ -44,8 +44,10 @@ import {
44
44
  import {
45
45
  claimQuestionPush,
46
46
  clearSessionState,
47
+ dropPendingQuestion,
47
48
  drainRetirements,
48
49
  drainOrphanRetirements,
50
+ handleSessionEnd,
49
51
  runEscalationWaiter,
50
52
  MAX_CONTINUATION_COUNT,
51
53
  MAX_HELD_DELIVERIES,
@@ -61,16 +63,17 @@ import {
61
63
  writeSessionState as persistSessionState,
62
64
  type PendingQuestion,
63
65
  type SessionState,
64
- waiterCeilingSeconds,
65
- WAITER_CEILING_SECONDS,
66
- DETACHED_WAITER_CEILING_SECONDS,
67
66
  } from './hooks.js'
68
67
  import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
69
- import {
70
- BLOCKING_STOP_TIMEOUT_SECONDS,
71
- CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
72
- } from './install-hooks.js'
68
+ import { QUESTION_STOP_TIMEOUT_SECONDS } from './install-hooks.js'
69
+ import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
73
70
  import { GUIDANCE_CONTEXT_MAX_BYTES } from './guidance-render.js'
71
+ import {
72
+ disableProject,
73
+ enableProject,
74
+ projectBinding,
75
+ projectEnabled,
76
+ } from './project-enablement.js'
74
77
 
75
78
  /** New-format test fixtures always carry the canonical body explicitly. */
76
79
  function registerQuestion(
@@ -322,6 +325,9 @@ function harness(replies: ReplyView[] = []): Harness {
322
325
  // Virtual clock: sleeps advance it instead of costing wall time. A frozen
323
326
  // clock would make the reply poll's deadline unreachable and spin forever.
324
327
  let clock = NOW
328
+ const binding = projectBinding(root, env)
329
+ if (binding === null) throw new Error('test Project binding unavailable')
330
+ enableProject(binding, new Date(NOW))
325
331
  return {
326
332
  io,
327
333
  recorder,
@@ -506,23 +512,12 @@ function writeGlobalConfig(h: Harness, toml: string): void {
506
512
  const AWAY = NOW - 600_000
507
513
 
508
514
  describe('the waiter wall clock', () => {
509
- it('spends a held turn sparingly and a detached one generously', () => {
510
- // A held turn is somebody's terminal, so the short ceiling is the cost of
511
- // waiting. Detached, nothing is held, and the same number only shortens how
512
- // long a direct wake stays possible.
513
- expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
514
- expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
515
- expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
516
- })
517
-
518
- it('stays under the timeout the async Stop handler declares', () => {
519
- // That declaration's kill is silent: past it the waiter vanishes and the
520
- // answer the user already gave is never delivered.
521
- expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
515
+ it('keeps both ownership routes alive beyond the longest answer window', () => {
516
+ expect(QUESTION_WAITER_CEILING_SECONDS).toBeGreaterThan(REPLY_MAX_WINDOW_SECONDS)
522
517
  })
523
518
 
524
- it('leaves the blocking host headroom above the waiter it declares for', () => {
525
- expect(BLOCKING_STOP_TIMEOUT_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
519
+ it('stays under the timeout every Question Routing Stop declares', () => {
520
+ expect(QUESTION_WAITER_CEILING_SECONDS).toBeLessThan(QUESTION_STOP_TIMEOUT_SECONDS)
526
521
  })
527
522
  })
528
523
 
@@ -566,8 +561,9 @@ describe('pushing a registered question', () => {
566
561
  title: 'Ship it?',
567
562
  body: 'Ship it?',
568
563
  })
569
- // A pushed question is a question, and a question has an attention tone.
570
- expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
564
+ // Kind default sounds are resolved server-side; stamping one here would
565
+ // skip Account and Project maps.
566
+ expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBeUndefined()
571
567
  })
572
568
 
573
569
  it('routes a registered question to an answer-capable Android installation', async () => {
@@ -599,7 +595,7 @@ describe('pushing a registered question', () => {
599
595
  mode: 'selected',
600
596
  device_ids: ['dev_android'],
601
597
  })
602
- expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBe('attention')
598
+ expect(h.recorder.submitted[0]?.draft.platform?.android?.sound).toBeUndefined()
603
599
  })
604
600
 
605
601
  it('keeps sparse alt text paired with its original media item', async () => {
@@ -930,10 +926,9 @@ describe('terminal-first grace window', () => {
930
926
  expect(h.recorder.submitted).toHaveLength(0)
931
927
  })
932
928
 
933
- it('never lets the longest window crowd out a reply window the server accepts', async () => {
934
- // The maximum grace a user can configure still has to leave the protocol's
935
- // 60s minimum inside the waiter ceiling. Real timers do not wake on the
936
- // exact requested millisecond, so the sleep deliberately overshoots.
929
+ it('keeps the longest grace inside the owner startup allowance', async () => {
930
+ // Real timers do not wake on the exact requested millisecond, so the sleep
931
+ // deliberately overshoots the supported six-minute preference.
937
932
  const h = harness([reply({ text: 'Yes' })])
938
933
  const virtualSleep = h.deps.sleep!
939
934
  h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
@@ -1015,9 +1010,8 @@ describe('terminal-first grace window', () => {
1015
1010
  return JSON.stringify({ session_id: 'transport-budget' })
1016
1011
  })
1017
1012
 
1018
- // The waiter's 480s ceiling is the last moment it will accept an answer;
1019
- // closing the window and retiring the card happens after it, which is what
1020
- // the blocking hosts' extra minute of declared headroom is there for.
1013
+ // This fixture commits a short eight-minute answer window. Transport and
1014
+ // the close fence still fit inside the Stop definition's teardown margin.
1021
1015
  expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 540_000)
1022
1016
  expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
1023
1017
  })
@@ -1214,6 +1208,74 @@ describe('OpenCode answer continuation', () => {
1214
1208
  })
1215
1209
 
1216
1210
  describe('the waiter owning one question to the end', () => {
1211
+ it('does not admit a max-window question after setup consumes its allowance', async () => {
1212
+ const h = harness([])
1213
+ writeGlobalConfig(h, `reply_window_seconds = ${REPLY_MAX_WINDOW_SECONDS}\n`)
1214
+ const factory = h.deps.clientFactory
1215
+ h.deps.clientFactory = () => {
1216
+ const client = factory!()
1217
+ return {
1218
+ ...client,
1219
+ listDevices: async () => {
1220
+ h.advanceClock(10 * 60 * 1000 + 1)
1221
+ return client.listDevices()
1222
+ },
1223
+ } as ApiClient
1224
+ }
1225
+ writeSessionState('setup-overrun', h.env, { last_prompt_at: AWAY })
1226
+ registerQuestion('setup-overrun', h.env, { question: 'Deploy?' }, NOW)
1227
+
1228
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'setup-overrun' }))
1229
+
1230
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual([])
1231
+ const pending = readSessionState('setup-overrun', h.env).pending?.[0]
1232
+ expect(pending?.request_id).toBeUndefined()
1233
+ expect(pending?.submission?.request_id).toBeDefined()
1234
+ expect(h.io.errLines.join('\n')).toContain('setup consumed the admission allowance')
1235
+ })
1236
+
1237
+ it('continues the same turn when the default one-day answer arrives just before expiry', async () => {
1238
+ const h = harness([])
1239
+ const answerAt = NOW + (86_400 - 10) * 1000
1240
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1241
+ const factory = h.deps.clientFactory
1242
+ h.deps.clientFactory = () => {
1243
+ const client = factory!()
1244
+ return {
1245
+ ...client,
1246
+ replies: async (
1247
+ requestId: string,
1248
+ options: { waitSeconds: number; afterSeq: number },
1249
+ ) => {
1250
+ const remaining = Math.max(0, answerAt - (h.deps.now?.() ?? NOW))
1251
+ h.advanceClock(Math.min(options.waitSeconds * 1000, remaining))
1252
+ const response = await client.replies(requestId, options)
1253
+ return {
1254
+ ...response,
1255
+ replies:
1256
+ (h.deps.now?.() ?? NOW) >= answerAt
1257
+ ? [reply({ text: 'Answer near the one-day edge' })]
1258
+ : [],
1259
+ }
1260
+ },
1261
+ } as ApiClient
1262
+ }
1263
+ writeSessionState('full-default-window', h.env, { last_prompt_at: AWAY })
1264
+ registerQuestion('full-default-window', h.env, { question: 'Deploy?' }, NOW)
1265
+
1266
+ await hookRunCommand(
1267
+ h.deps,
1268
+ 'stop',
1269
+ stdin({ session_id: 'full-default-window' }),
1270
+ 'codex',
1271
+ )
1272
+
1273
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThan(8 * 60 * 1000)
1274
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain(
1275
+ 'Answer near the one-day edge',
1276
+ )
1277
+ })
1278
+
1217
1279
  it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
1218
1280
  const h = harness([])
1219
1281
  const factory = h.deps.clientFactory
@@ -1487,7 +1549,7 @@ describe('the waiter owning one question to the end', () => {
1487
1549
  })
1488
1550
  })
1489
1551
 
1490
- it('closes and retires silence at the absolute owner deadline', async () => {
1552
+ it('does not close silence before the committed answer deadline', async () => {
1491
1553
  const h = harness([])
1492
1554
  const factory = h.deps.clientFactory
1493
1555
  h.deps.clientFactory = () => {
@@ -1496,8 +1558,8 @@ describe('the waiter owning one question to the end', () => {
1496
1558
  ...client,
1497
1559
  submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => ({
1498
1560
  ...(await client.submit(body, waitSeconds)),
1499
- // The real server commits the configured reply window, rather than
1500
- // the generic recorder's deliberately broad 480s fixture.
1561
+ // Pin a short real-server deadline so the virtual clock proves the
1562
+ // waiter did not borrow even its close fence from the answer window.
1501
1563
  reply_expires_at: new Date(NOW + 60_000).toISOString(),
1502
1564
  }),
1503
1565
  } as ApiClient
@@ -1507,12 +1569,99 @@ describe('the waiter owning one question to the end', () => {
1507
1569
 
1508
1570
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
1509
1571
 
1510
- expect(h.deps.now?.()).toBe(NOW + 57_000)
1572
+ expect(h.deps.now?.()).toBe(NOW + 60_000)
1511
1573
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1512
1574
  expect(readSessionState('deadline', h.env).pending).toBeUndefined()
1513
1575
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
1514
1576
  })
1515
1577
 
1578
+ it('closes a server window whose committed deadline exceeds its process owner', async () => {
1579
+ const h = harness([])
1580
+ h.recorder.replyExpiresAt = new Date(
1581
+ NOW + (QUESTION_WAITER_CEILING_SECONDS + 60) * 1000,
1582
+ ).toISOString()
1583
+ writeSessionState('deadline-overrun', h.env, { last_prompt_at: AWAY })
1584
+ registerQuestion('deadline-overrun', h.env, { question: 'Deploy?' }, NOW)
1585
+
1586
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline-overrun' }))
1587
+
1588
+ expect(h.recorder.closed).toEqual([h.recorder.receipts[0]])
1589
+ expect(readSessionState('deadline-overrun', h.env).pending).toBeUndefined()
1590
+ expect(h.io.errLines.join('\n')).toContain('deadline beyond this process owner')
1591
+ })
1592
+
1593
+ it('keeps anomalous live state when immediate closure is unreachable', async () => {
1594
+ const h = harness([])
1595
+ h.recorder.failCloses = true
1596
+ h.recorder.replyExpiresAt = new Date(
1597
+ NOW + (QUESTION_WAITER_CEILING_SECONDS + 60) * 1000,
1598
+ ).toISOString()
1599
+ writeSessionState('deadline-recovery', h.env, { last_prompt_at: AWAY })
1600
+ registerQuestion('deadline-recovery', h.env, { question: 'Deploy?' }, NOW)
1601
+
1602
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline-recovery' }))
1603
+
1604
+ const live = readSessionState('deadline-recovery', h.env).pending?.[0]
1605
+ expect(live?.request_id).toBe(h.recorder.receipts[0])
1606
+ expect(h.io.errLines.join('\n')).toContain('preserving the live question')
1607
+
1608
+ h.recorder.failCloses = false
1609
+ h.recorder.repliesFor = (requestId) =>
1610
+ requestId === live?.request_id ? [reply({ text: 'Recovered answer' })] : []
1611
+ h.io.outLines = []
1612
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline-recovery' }))
1613
+
1614
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Recovered answer')
1615
+ })
1616
+
1617
+ it('parks anomalous retirement when terminal input removes the frozen entry', async () => {
1618
+ const h = harness([])
1619
+ h.recorder.failCloses = true
1620
+ h.recorder.replyExpiresAt = new Date(
1621
+ NOW + (QUESTION_WAITER_CEILING_SECONDS + 60) * 1000,
1622
+ ).toISOString()
1623
+ const factory = h.deps.clientFactory!
1624
+ let markClose: (() => void) | undefined
1625
+ const closeStarted = new Promise<void>((resolve) => {
1626
+ markClose = resolve
1627
+ })
1628
+ let releaseClose: (() => void) | undefined
1629
+ const heldClose = new Promise<void>((resolve) => {
1630
+ releaseClose = resolve
1631
+ })
1632
+ h.deps.clientFactory = (...args) => {
1633
+ const client = factory(...args)
1634
+ return {
1635
+ ...client,
1636
+ closeReplies: async (requestId: string) => {
1637
+ markClose?.()
1638
+ await heldClose
1639
+ return client.closeReplies(requestId)
1640
+ },
1641
+ } as ApiClient
1642
+ }
1643
+ writeSessionState('deadline-prompt-race', h.env, { last_prompt_at: AWAY })
1644
+ registerQuestion('deadline-prompt-race', h.env, { question: 'Deploy?' }, NOW)
1645
+
1646
+ const stop = hookRunCommand(
1647
+ h.deps,
1648
+ 'stop',
1649
+ stdin({ session_id: 'deadline-prompt-race' }),
1650
+ )
1651
+ await closeStarted
1652
+ const frozen = readSessionState('deadline-prompt-race', h.env).pending?.[0]
1653
+ if (frozen === undefined) throw new Error('expected frozen submission state')
1654
+ dropPendingQuestion('deadline-prompt-race', h.env, frozen)
1655
+ releaseClose?.()
1656
+ await stop
1657
+
1658
+ expect(
1659
+ readSessionState('deadline-prompt-race', h.env).retiring?.map(
1660
+ (entry) => entry.request_id,
1661
+ ),
1662
+ ).toEqual([h.recorder.receipts[0]])
1663
+ })
1664
+
1516
1665
  it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
1517
1666
  const h = harness([reply({ text: 'Done' })])
1518
1667
  writeSessionState('latency', h.env, { last_prompt_at: AWAY })
@@ -1529,9 +1678,11 @@ describe('the waiter owning one question to the end', () => {
1529
1678
  // part of its own budget getting the question out. Both questions stay
1530
1679
  // answerable for the configured window.
1531
1680
  expect(windows).toEqual([86_400, 86_400])
1532
- // The owner's own budget is still one shared deadline, and it is still
1533
- // charged for the latency: the whole pass fits inside the waiter ceiling.
1534
- expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
1681
+ // Submission latency is charged to the one owner lifetime, without
1682
+ // shrinking either server answer window.
1683
+ expect(h.deps.now?.()).toBeLessThanOrEqual(
1684
+ NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
1685
+ )
1535
1686
  })
1536
1687
 
1537
1688
  it('re-arms the next Stop for an independently pending answer', async () => {
@@ -1590,7 +1741,7 @@ describe('the waiter owning one question to the end', () => {
1590
1741
  expect(h.deps.now?.()).toBeLessThan(started + 30_000)
1591
1742
  expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1592
1743
  expect(readSessionState('spent-owner', h.env).pending?.[0]?.request_id).toBe('req_old')
1593
- expect(h.io.errLines.join('\n')).toContain('previous waiter already used its ceiling')
1744
+ expect(h.io.errLines.join('\n')).toContain('previous answer owner ended')
1594
1745
  })
1595
1746
 
1596
1747
  it('does not re-arm a live question written before owner leases were persisted', async () => {
@@ -1619,7 +1770,7 @@ describe('the waiter owning one question to the end', () => {
1619
1770
  )
1620
1771
  })
1621
1772
 
1622
- it('pushes a newly registered question on the next Stop after a spent waiter', async () => {
1773
+ it('pushes and owns a newly registered question after a spent legacy waiter', async () => {
1623
1774
  const h = harness([])
1624
1775
  h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1625
1776
  writeSessionState('after-degraded', h.env, {
@@ -1643,8 +1794,9 @@ describe('the waiter owning one question to the end', () => {
1643
1794
  const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
1644
1795
  expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['New question?'])
1645
1796
  expect(readSessionState('after-degraded', h.env).pending?.map((entry) => entry.question)).toEqual(
1646
- ['Earlier question?', 'New question?'],
1797
+ ['Earlier question?'],
1647
1798
  )
1799
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThan(8 * 60 * 1000)
1648
1800
  })
1649
1801
 
1650
1802
  it('still waits out a live owner lease for an independent answer', async () => {
@@ -2428,8 +2580,8 @@ describe('hostile input', () => {
2428
2580
  mkdirSync(path.join(project, '.notifai'), { recursive: true })
2429
2581
  writeFileSync(path.join(project, '.notifai', 'config.toml'), 'ask_grace_seconds = 99999\n')
2430
2582
  const config = loadConfig({ cwd: project, env: h.env })
2431
- // A committed repository file must not be able to consume the waiter's
2432
- // whole ceiling and leave no window in which an answer is still accepted.
2583
+ // A committed repository file must not be able to replace the supported
2584
+ // terminal-first preference with an arbitrary multi-hour hold.
2433
2585
  expect(config.ask_grace_seconds.value).toBeLessThanOrEqual(360)
2434
2586
  })
2435
2587
  })
@@ -2632,7 +2784,31 @@ describe('ask registration', () => {
2632
2784
  })
2633
2785
 
2634
2786
  describe('session-start hook', () => {
2635
- it('adds proactive Notifai context before setup or machine authentication exists', async () => {
2787
+ it('is completely silent when the Project is disabled', async () => {
2788
+ const h = harness()
2789
+ const binding = projectBinding(h.deps.cwd, h.env)
2790
+ if (binding === null) throw new Error('test Project binding unavailable')
2791
+ disableProject(binding)
2792
+
2793
+ for (const [event, hookHarness] of [
2794
+ ['session-start', 'claude-code'],
2795
+ ['subagent-start', 'codex'],
2796
+ ['activation-stop', 'cursor'],
2797
+ ] as const) {
2798
+ h.io.outLines = []
2799
+ await hookRunCommand(
2800
+ h.deps,
2801
+ event,
2802
+ stdin({ session_id: 'disabled', conversation_id: 'disabled', workspace_roots: [h.deps.cwd], loop_count: 0 }),
2803
+ hookHarness,
2804
+ )
2805
+ expect(h.io.outLines).toEqual([])
2806
+ }
2807
+ expect(projectEnabled(binding)).toBe(false)
2808
+ expect(readSessionState('disabled', h.env).started_at).toBeUndefined()
2809
+ })
2810
+
2811
+ it('adds proactive Notifai context for an enabled Project before machine authentication exists', async () => {
2636
2812
  const h = harness()
2637
2813
  h.deps.store.load = () => null
2638
2814
 
@@ -2651,7 +2827,7 @@ describe('session-start hook', () => {
2651
2827
  expect(first.hookSpecificOutput).toMatchObject({
2652
2828
  hookEventName: 'SessionStart',
2653
2829
  })
2654
- expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*session/i)
2830
+ expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*enabled.*Project/i)
2655
2831
  expect(first.hookSpecificOutput?.additionalContext).toContain('# How to read this guidance')
2656
2832
  expect(first.hookSpecificOutput?.additionalContext).toContain(
2657
2833
  '<!-- notifai:guidance topic=when-to-notify from=shipped default -->',
@@ -2696,7 +2872,7 @@ describe('session-start hook', () => {
2696
2872
  'cursor',
2697
2873
  )
2698
2874
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2699
- additional_context: expect.stringMatching(/Notifai.*session/i),
2875
+ additional_context: expect.stringMatching(/Notifai.*enabled.*Project/i),
2700
2876
  })
2701
2877
 
2702
2878
  h.io.outLines = []
@@ -3999,6 +4175,111 @@ describe('two hooks racing one question', () => {
3999
4175
  expect(h.io.errLines.join(" ")).toContain('already handling')
4000
4176
  })
4001
4177
 
4178
+ it('waits past fifteen seconds for an older owner to hand off a newer ask', async () => {
4179
+ const h = harness([reply({ text: 'Continue' })])
4180
+ writeSessionState('race-long-handoff', h.env, { last_prompt_at: AWAY })
4181
+ registerQuestion('race-long-handoff', h.env, { question: 'Original?' }, NOW)
4182
+ expect(claimQuestionPush('race-long-handoff', h.env, Date.now())).toBe(true)
4183
+ h.advanceClock(1)
4184
+ registerQuestion(
4185
+ 'race-long-handoff',
4186
+ h.env,
4187
+ { question: 'New independent ask?' },
4188
+ NOW + 1,
4189
+ )
4190
+ const startedAt = h.deps.now!()
4191
+ let handedOff = false
4192
+ h.deps.sleep = async (milliseconds: number) => {
4193
+ h.advanceClock(milliseconds)
4194
+ if (!handedOff && h.deps.now!() - startedAt >= 20_000) {
4195
+ handedOff = true
4196
+ releaseQuestionPush('race-long-handoff', h.env)
4197
+ }
4198
+ }
4199
+
4200
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-long-handoff' }))
4201
+
4202
+ expect(handedOff).toBe(true)
4203
+ expect(h.deps.now!() - startedAt).toBeGreaterThanOrEqual(20_000)
4204
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
4205
+ expect(h.io.errLines.join(' ')).not.toContain('claimed-elsewhere')
4206
+ })
4207
+
4208
+ it('waits through the holder teardown margin instead of stranding a boundary ask', async () => {
4209
+ const h = harness([reply({ text: 'Continue' })])
4210
+ writeSessionState('race-holder-deadline', h.env, { last_prompt_at: AWAY })
4211
+ registerQuestion('race-holder-deadline', h.env, { question: 'Original?' }, NOW)
4212
+ expect(
4213
+ claimQuestionPush(
4214
+ 'race-holder-deadline',
4215
+ h.env,
4216
+ Date.now(),
4217
+ undefined,
4218
+ NOW + 20_000,
4219
+ ),
4220
+ ).toBe(true)
4221
+ h.advanceClock(1)
4222
+ registerQuestion(
4223
+ 'race-holder-deadline',
4224
+ h.env,
4225
+ { question: 'New?' },
4226
+ NOW + 1,
4227
+ )
4228
+ let released = false
4229
+ h.deps.sleep = async (milliseconds: number) => {
4230
+ h.advanceClock(milliseconds)
4231
+ if (!released && h.deps.now!() >= NOW + 25_000) {
4232
+ released = true
4233
+ releaseQuestionPush('race-holder-deadline', h.env)
4234
+ }
4235
+ }
4236
+
4237
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-holder-deadline' }))
4238
+
4239
+ expect(released).toBe(true)
4240
+ expect(h.deps.now!()).toBeGreaterThanOrEqual(NOW + 25_000)
4241
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
4242
+ })
4243
+
4244
+ it('stops waiting when another successor snapshots the new ask', async () => {
4245
+ const h = harness([])
4246
+ writeSessionState('race-resnapshot', h.env, { last_prompt_at: AWAY })
4247
+ registerQuestion('race-resnapshot', h.env, { question: 'Original?' }, NOW)
4248
+ expect(
4249
+ claimQuestionPush(
4250
+ 'race-resnapshot',
4251
+ h.env,
4252
+ Date.now(),
4253
+ undefined,
4254
+ NOW + 60_000,
4255
+ ),
4256
+ ).toBe(true)
4257
+ h.advanceClock(1)
4258
+ registerQuestion('race-resnapshot', h.env, { question: 'New?' }, NOW + 1)
4259
+ const startedAt = h.deps.now!()
4260
+ let replacementClaimed = false
4261
+ h.deps.sleep = async (milliseconds: number) => {
4262
+ h.advanceClock(milliseconds)
4263
+ if (!replacementClaimed && h.deps.now!() - startedAt >= 20_000) {
4264
+ releaseQuestionPush('race-resnapshot', h.env)
4265
+ replacementClaimed = claimQuestionPush(
4266
+ 'race-resnapshot',
4267
+ h.env,
4268
+ Date.now(),
4269
+ undefined,
4270
+ NOW + 40_000,
4271
+ )
4272
+ }
4273
+ }
4274
+
4275
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-resnapshot' }))
4276
+
4277
+ expect(replacementClaimed).toBe(true)
4278
+ expect(h.deps.now!() - startedAt).toBe(20_000)
4279
+ expect(h.recorder.submitted).toEqual([])
4280
+ releaseQuestionPush('race-resnapshot', h.env)
4281
+ })
4282
+
4002
4283
  it('resumes the agent once when two Stops collect the same late answer', async () => {
4003
4284
  const answers: ReplyView[] = []
4004
4285
  const h = harness(answers)
@@ -4044,10 +4325,54 @@ describe('two hooks racing one question', () => {
4044
4325
  expect(polls).toBe(1)
4045
4326
  expect(resumes.filter((entry) => entry.decision === 'block')).toHaveLength(1)
4046
4327
  })
4328
+
4329
+ it('hands the claim to a later Stop so its independent question is sent now', async () => {
4330
+ const h = harness([])
4331
+ h.deps.sleep = async (milliseconds: number) => {
4332
+ h.advanceClock(milliseconds)
4333
+ await new Promise<void>((resolve) => setImmediate(resolve))
4334
+ }
4335
+ h.recorder.repliesFor = (requestId) =>
4336
+ h.recorder.aliases.get(requestId) === 2
4337
+ ? [reply({ text: 'Answer the newer question' })]
4338
+ : []
4339
+ let markFirstPoll: (() => void) | undefined
4340
+ const firstPoll = new Promise<void>((resolve) => {
4341
+ markFirstPoll = resolve
4342
+ })
4343
+ let releaseFirstPoll: (() => void) | undefined
4344
+ const heldFirstPoll = new Promise<void>((resolve) => {
4345
+ releaseFirstPoll = resolve
4346
+ })
4347
+ let hold = true
4348
+ h.recorder.beforeReplies = async () => {
4349
+ if (!hold) return
4350
+ hold = false
4351
+ markFirstPoll?.()
4352
+ await heldFirstPoll
4353
+ }
4354
+ writeSessionState('claim-handoff', h.env, { last_prompt_at: AWAY })
4355
+ registerQuestion('claim-handoff', h.env, { question: 'Older question?' }, NOW)
4356
+
4357
+ const firstStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'claim-handoff' }))
4358
+ await firstPoll
4359
+ registerQuestion('claim-handoff', h.env, { question: 'Newer question?' }, NOW + 1)
4360
+ const secondStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'claim-handoff' }))
4361
+ await new Promise<void>((resolve) => setImmediate(resolve))
4362
+ releaseFirstPoll?.()
4363
+ await Promise.all([firstStop, secondStop])
4364
+
4365
+ const sent = h.recorder.submitted
4366
+ .filter((entry) => isQuestionSubmit(entry))
4367
+ .map((entry) => entry.draft.presentation.body)
4368
+ expect(sent).toEqual(['Older question?', 'Newer question?'])
4369
+ expect(h.io.outLines.join('\n')).toContain('Answer the newer question')
4370
+ expect(h.io.errLines.join('\n')).toContain('yielding the answer owner')
4371
+ })
4047
4372
  })
4048
4373
 
4049
4374
  describe('question registration racing a Stop submission', () => {
4050
- it('keeps the new question when the older owner expires', async () => {
4375
+ it('preserves both questions while the old owner yields to the new ask', async () => {
4051
4376
  const h = harness([])
4052
4377
  writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
4053
4378
  registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
@@ -4058,19 +4383,19 @@ describe('question registration racing a Stop submission', () => {
4058
4383
 
4059
4384
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
4060
4385
 
4061
- // The racing ask appended and remains unasked. The already-delivered old
4062
- // question is retired when this Stop owner reaches its deadline, so it
4063
- // cannot accept a phone answer after the hook has returned.
4386
+ // The racing ask is a handoff signal. This owner leaves the delivered old
4387
+ // question and the new unasked one intact for the successor Stop.
4064
4388
  const state = readSessionState('submit-race', h.env)
4065
- expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
4066
- expect(state.pending?.[0]?.request_id).toBeUndefined()
4067
- expect(h.recorder.closed).toContain(h.recorder.receipts[0])
4068
- expect(state.retiring).toContainEqual(
4069
- expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
4070
- )
4389
+ expect(state.pending?.map((entry) => entry.question)).toEqual([
4390
+ 'Old question?',
4391
+ 'New question?',
4392
+ ])
4393
+ expect(state.pending?.[0]?.request_id).toBe(h.recorder.receipts[0])
4394
+ expect(state.pending?.[1]?.request_id).toBeUndefined()
4395
+ expect(h.io.errLines.join('\n')).toContain('yielding the answer owner')
4071
4396
  })
4072
4397
 
4073
- it('keeps the newer question when the older in-flight question receives an answer', async () => {
4398
+ it('lets the successor owner collect an old answer without losing the newer question', async () => {
4074
4399
  const h = harness([reply({ text: 'Old answer' })])
4075
4400
  writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
4076
4401
  registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
@@ -4079,6 +4404,7 @@ describe('question registration racing a Stop submission', () => {
4079
4404
  registerQuestion('answer-race', h.env, { question: 'New question?' }, NOW + 1)
4080
4405
  }
4081
4406
 
4407
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
4082
4408
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
4083
4409
 
4084
4410
  const state = readSessionState('answer-race', h.env)
@@ -4171,6 +4497,194 @@ describe('Claude Code Stop wake route', () => {
4171
4497
  expect(readSessionState('claude-route', h.env).accepted).toBeDefined()
4172
4498
  })
4173
4499
 
4500
+ it('keeps the detached owner through the default one-day answer window', async () => {
4501
+ const h = harness([])
4502
+ const answerAt = NOW + (86_400 - 10) * 1000
4503
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
4504
+ const factory = h.deps.clientFactory
4505
+ h.deps.clientFactory = () => {
4506
+ const client = factory!()
4507
+ return {
4508
+ ...client,
4509
+ replies: async (
4510
+ requestId: string,
4511
+ options: { waitSeconds: number; afterSeq: number },
4512
+ ) => {
4513
+ const remaining = Math.max(0, answerAt - (h.deps.now?.() ?? NOW))
4514
+ h.advanceClock(Math.min(options.waitSeconds * 1000, remaining))
4515
+ const response = await client.replies(requestId, options)
4516
+ return {
4517
+ ...response,
4518
+ replies:
4519
+ (h.deps.now?.() ?? NOW) >= answerAt
4520
+ ? [reply({ text: 'Wake near the one-day edge' })]
4521
+ : [],
4522
+ }
4523
+ },
4524
+ } as ApiClient
4525
+ }
4526
+ writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
4527
+ registerQuestion('claude-route', h.env, { question: 'Deploy?' }, NOW)
4528
+ const wake = claudeWake()
4529
+
4530
+ await hookRunCommand(
4531
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
4532
+ 'stop',
4533
+ stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
4534
+ 'claude-code',
4535
+ )
4536
+
4537
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThan(8 * 60 * 1000)
4538
+ expect(wake.sent).toHaveLength(1)
4539
+ expect(wake.sent[0]).toContain('Wake near the one-day edge')
4540
+ })
4541
+
4542
+ it('stops a detached observer when SessionEnd removes its ownership', async () => {
4543
+ const h = harness([reply({ text: 'Too late' })])
4544
+ let markPoll: (() => void) | undefined
4545
+ const pollStarted = new Promise<void>((resolve) => {
4546
+ markPoll = resolve
4547
+ })
4548
+ let releasePoll: (() => void) | undefined
4549
+ const heldPoll = new Promise<void>((resolve) => {
4550
+ releasePoll = resolve
4551
+ })
4552
+ h.recorder.beforeReplies = async () => {
4553
+ markPoll?.()
4554
+ await heldPoll
4555
+ }
4556
+ writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
4557
+ registerQuestion('claude-route', h.env, { question: 'Deploy?' }, NOW)
4558
+ const wake = claudeWake()
4559
+ const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
4560
+
4561
+ const stop = hookRunCommand(
4562
+ deps,
4563
+ 'stop',
4564
+ stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
4565
+ 'claude-code',
4566
+ )
4567
+ await pollStarted
4568
+ await hookRunCommand(
4569
+ deps,
4570
+ 'session-end',
4571
+ stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
4572
+ 'claude-code',
4573
+ )
4574
+ releasePoll?.()
4575
+ await stop
4576
+
4577
+ expect(wake.sent).toEqual([])
4578
+ expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
4579
+ expect(h.io.errLines.join('\n')).toContain('stopping this observer')
4580
+ expect(claimQuestionPush('claude-route', h.env)).toBe(true)
4581
+ releaseQuestionPush('claude-route', h.env)
4582
+ })
4583
+
4584
+ it('does not resurrect or wake an ended session after its close fence returns', async () => {
4585
+ const h = harness([reply({ text: 'Too late' })])
4586
+ const factory = h.deps.clientFactory!
4587
+ let markClose: (() => void) | undefined
4588
+ const closeStarted = new Promise<void>((resolve) => {
4589
+ markClose = resolve
4590
+ })
4591
+ let releaseClose: (() => void) | undefined
4592
+ const heldClose = new Promise<void>((resolve) => {
4593
+ releaseClose = resolve
4594
+ })
4595
+ h.deps.clientFactory = (...args) => {
4596
+ const client = factory(...args)
4597
+ return {
4598
+ ...client,
4599
+ closeReplies: async (requestId: string) => {
4600
+ markClose?.()
4601
+ await heldClose
4602
+ return client.closeReplies(requestId)
4603
+ },
4604
+ } as ApiClient
4605
+ }
4606
+ writeSessionState('claude-fence-end', h.env, { last_prompt_at: AWAY })
4607
+ registerQuestion('claude-fence-end', h.env, { question: 'Deploy?' }, NOW)
4608
+ const wake = claudeWake()
4609
+ const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
4610
+
4611
+ const stop = hookRunCommand(
4612
+ deps,
4613
+ 'stop',
4614
+ stdin({ session_id: 'claude-fence-end', cwd: '/tmp/claude-fence-end' }),
4615
+ 'claude-code',
4616
+ )
4617
+ await closeStarted
4618
+ await hookRunCommand(
4619
+ deps,
4620
+ 'session-end',
4621
+ stdin({ session_id: 'claude-fence-end', cwd: '/tmp/claude-fence-end' }),
4622
+ 'claude-code',
4623
+ )
4624
+ releaseClose?.()
4625
+ await stop
4626
+
4627
+ expect(wake.sent).toEqual([])
4628
+ expect(readSessionState('claude-fence-end', h.env).accepted).toBeUndefined()
4629
+ expect(h.io.errLines.join('\n')).toContain('ended before answer delivery')
4630
+ })
4631
+
4632
+ it('globally retires a submission that commits after SessionEnd', async () => {
4633
+ const h = harness([])
4634
+ const factory = h.deps.clientFactory!
4635
+ let markSubmit: (() => void) | undefined
4636
+ const submitStarted = new Promise<void>((resolve) => {
4637
+ markSubmit = resolve
4638
+ })
4639
+ let releaseSubmit: (() => void) | undefined
4640
+ const heldSubmit = new Promise<void>((resolve) => {
4641
+ releaseSubmit = resolve
4642
+ })
4643
+ h.deps.clientFactory = (...args) => {
4644
+ const client = factory(...args)
4645
+ return {
4646
+ ...client,
4647
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
4648
+ if (body.draft.reply !== undefined) {
4649
+ markSubmit?.()
4650
+ await heldSubmit
4651
+ }
4652
+ return client.submit(body, waitSeconds)
4653
+ },
4654
+ } as ApiClient
4655
+ }
4656
+ writeSessionState('claude-submit-end', h.env, { last_prompt_at: AWAY })
4657
+ registerQuestion('claude-submit-end', h.env, { question: 'Deploy?' }, NOW)
4658
+ const wake = claudeWake()
4659
+ const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
4660
+
4661
+ const stop = hookRunCommand(
4662
+ deps,
4663
+ 'stop',
4664
+ stdin({ session_id: 'claude-submit-end', cwd: '/tmp/claude-submit-end' }),
4665
+ 'claude-code',
4666
+ )
4667
+ await submitStarted
4668
+ await hookRunCommand(
4669
+ deps,
4670
+ 'session-end',
4671
+ stdin({ session_id: 'claude-submit-end', cwd: '/tmp/claude-submit-end' }),
4672
+ 'claude-code',
4673
+ )
4674
+ releaseSubmit?.()
4675
+ await stop
4676
+
4677
+ const client = factory('https://test.notifai.invalid', 'Bearer test')
4678
+ await drainOrphanRetirements(
4679
+ { client, config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
4680
+ h.env,
4681
+ NOW,
4682
+ )
4683
+ expect(wake.sent).toEqual([])
4684
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
4685
+ expect(readSessionState('claude-submit-end', h.env).pending).toBeUndefined()
4686
+ })
4687
+
4174
4688
  /** One answer already accepted and journaled, ready for the next Stop. */
4175
4689
  function journaledAnswer(
4176
4690
  h: Harness,
@@ -4488,7 +5002,7 @@ describe('escalation waiter delivery seam', () => {
4488
5002
  sessionId: 'waiter-route',
4489
5003
  envelope: { session_id: 'waiter-route' },
4490
5004
  route: { kind: 'hook-continuation', deliver },
4491
- processDeadlineAt: NOW + 480_000,
5005
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4492
5006
  })
4493
5007
 
4494
5008
  expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
@@ -4548,7 +5062,7 @@ describe('escalation waiter delivery seam', () => {
4548
5062
  return { stdout: JSON.stringify({ decision: 'block', reason: event.context }), notes: [] }
4549
5063
  },
4550
5064
  },
4551
- processDeadlineAt: NOW + 480_000,
5065
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4552
5066
  })
4553
5067
 
4554
5068
  expect(phases.filter((phase) => phase === 'grace').length).toBeGreaterThan(0)
@@ -4561,6 +5075,74 @@ describe('escalation waiter delivery seam', () => {
4561
5075
  releaseQuestionPush('waiter-race-lifetime', h.env)
4562
5076
  })
4563
5077
 
5078
+ it('keeps an unrelated answer when another owned question is retired mid-poll', async () => {
5079
+ const h = harness([])
5080
+ const first: PendingQuestion = {
5081
+ question: 'First?',
5082
+ body: 'First?',
5083
+ asked_at: NOW,
5084
+ request_id: 'req_partial_1',
5085
+ collapse_key: 'collapse-partial-1',
5086
+ device_ids: ['dev_iphone'],
5087
+ reply_deadline_at: NOW + 60_000,
5088
+ owner_deadline_at: NOW + 120_000,
5089
+ }
5090
+ const second: PendingQuestion = {
5091
+ question: 'Second?',
5092
+ body: 'Second?',
5093
+ asked_at: NOW,
5094
+ request_id: 'req_partial_2',
5095
+ collapse_key: 'collapse-partial-2',
5096
+ device_ids: ['dev_iphone'],
5097
+ reply_deadline_at: NOW + 60_000,
5098
+ owner_deadline_at: NOW + 120_000,
5099
+ }
5100
+ writeSessionState('waiter-partial', h.env, {
5101
+ last_prompt_at: AWAY,
5102
+ pending: [first, second],
5103
+ })
5104
+ const context = waiterContext(h)
5105
+ const polls = new Map<string, number>()
5106
+ let longPolls = 0
5107
+ let releaseLongPolls: (() => void) | undefined
5108
+ const heldLongPolls = new Promise<void>((resolve) => {
5109
+ releaseLongPolls = resolve
5110
+ })
5111
+ context.waitForFirstReply = async (requestId: string) => {
5112
+ const count = (polls.get(requestId) ?? 0) + 1
5113
+ polls.set(requestId, count)
5114
+ if (count === 1) return { replies: [], timedOut: true }
5115
+ longPolls += 1
5116
+ await heldLongPolls
5117
+ return {
5118
+ replies: requestId === second.request_id ? [reply({ text: 'Second answer' })] : [],
5119
+ timedOut: requestId !== second.request_id,
5120
+ }
5121
+ }
5122
+ const deliveries: string[] = []
5123
+
5124
+ const waiter = runEscalationWaiter(context, {
5125
+ sessionId: 'waiter-partial',
5126
+ envelope: { session_id: 'waiter-partial' },
5127
+ route: {
5128
+ kind: 'hook-continuation',
5129
+ deliver: async (event) => {
5130
+ deliveries.push(event.context)
5131
+ return { acknowledgement: 'stdout', stdout: event.context }
5132
+ },
5133
+ },
5134
+ processDeadlineAt: NOW + 120_000,
5135
+ })
5136
+ await waitUntil(() => longPolls === 2, 1_000, 'long reply polls did not start')
5137
+ dropPendingQuestion('waiter-partial', h.env, first)
5138
+ releaseLongPolls?.()
5139
+ await waiter
5140
+
5141
+ expect(deliveries).toHaveLength(1)
5142
+ expect(deliveries[0]).toContain('Second answer')
5143
+ expect(deliveries[0]).not.toContain('First?')
5144
+ })
5145
+
4564
5146
  /** One accepted answer on the journal, whatever route is about to own it. */
4565
5147
  function journaled(h: Harness, sessionId: string): void {
4566
5148
  writeSessionState(sessionId, h.env, {
@@ -4583,6 +5165,138 @@ describe('escalation waiter delivery seam', () => {
4583
5165
  })
4584
5166
  }
4585
5167
 
5168
+ it('lets SessionEnd cancel at the final route commit boundary', async () => {
5169
+ const h = harness([])
5170
+ journaled(h, 'waiter-final-cancel')
5171
+ const context = waiterContext(h)
5172
+ let markRoute: (() => void) | undefined
5173
+ const routeStarted = new Promise<void>((resolve) => {
5174
+ markRoute = resolve
5175
+ })
5176
+ let releaseRoute: (() => void) | undefined
5177
+ const heldRoute = new Promise<void>((resolve) => {
5178
+ releaseRoute = resolve
5179
+ })
5180
+ let wrote = false
5181
+
5182
+ const waiter = runEscalationWaiter(context, {
5183
+ sessionId: 'waiter-final-cancel',
5184
+ envelope: { session_id: 'waiter-final-cancel' },
5185
+ route: {
5186
+ kind: 'inbox-socket',
5187
+ deliver: async (event) => {
5188
+ markRoute?.()
5189
+ await heldRoute
5190
+ if (event.commitDelivery()) wrote = true
5191
+ return { acknowledgement: wrote ? 'delivered' : 'held' }
5192
+ },
5193
+ },
5194
+ processDeadlineAt: NOW + 120_000,
5195
+ })
5196
+ await routeStarted
5197
+ await hookRunCommand(
5198
+ h.deps,
5199
+ 'session-end',
5200
+ stdin({ session_id: 'waiter-final-cancel' }),
5201
+ 'claude-code',
5202
+ )
5203
+ releaseRoute?.()
5204
+ await waiter
5205
+
5206
+ expect(wrote).toBe(false)
5207
+ })
5208
+
5209
+ it('fences production blocking stdout immediately before the harness write', async () => {
5210
+ const h = harness([reply({ text: 'Too late for stdout' })])
5211
+ writeSessionState('waiter-stdout-cancel', h.env, { last_prompt_at: AWAY })
5212
+ registerQuestion('waiter-stdout-cancel', h.env, { question: 'Deploy?' }, NOW)
5213
+ const originalErr = h.io.err.bind(h.io)
5214
+ let ended = false
5215
+ h.io.err = (line: string) => {
5216
+ originalErr(line)
5217
+ if (!ended && line.includes('answer from')) {
5218
+ ended = true
5219
+ handleSessionEnd(h.env, { session_id: 'waiter-stdout-cancel' }, NOW)
5220
+ }
5221
+ }
5222
+
5223
+ await hookRunCommand(
5224
+ h.deps,
5225
+ 'stop',
5226
+ stdin({ session_id: 'waiter-stdout-cancel' }),
5227
+ 'codex',
5228
+ )
5229
+
5230
+ expect(ended).toBe(true)
5231
+ expect(h.io.outLines).toEqual([])
5232
+ expect(h.io.errLines.join('\n')).toContain('no continuation was written')
5233
+ })
5234
+
5235
+ it('finishes a delivery that committed before SessionEnd without replaying it', async () => {
5236
+ const h = harness([])
5237
+ journaled(h, 'waiter-commit-first')
5238
+ const context = waiterContext(h)
5239
+ let markCommitted: (() => void) | undefined
5240
+ const committed = new Promise<void>((resolve) => {
5241
+ markCommitted = resolve
5242
+ })
5243
+ let releaseWrite: (() => void) | undefined
5244
+ const heldWrite = new Promise<void>((resolve) => {
5245
+ releaseWrite = resolve
5246
+ })
5247
+ let writes = 0
5248
+
5249
+ const waiter = runEscalationWaiter(context, {
5250
+ sessionId: 'waiter-commit-first',
5251
+ envelope: { session_id: 'waiter-commit-first' },
5252
+ route: {
5253
+ kind: 'inbox-socket',
5254
+ deliver: async (event) => {
5255
+ expect(event.commitDelivery()).toBe(true)
5256
+ markCommitted?.()
5257
+ await heldWrite
5258
+ writes += 1
5259
+ return { acknowledgement: 'delivered' }
5260
+ },
5261
+ },
5262
+ processDeadlineAt: NOW + 120_000,
5263
+ })
5264
+ await committed
5265
+ await hookRunCommand(
5266
+ h.deps,
5267
+ 'session-end',
5268
+ stdin({ session_id: 'waiter-commit-first' }),
5269
+ 'claude-code',
5270
+ )
5271
+ releaseWrite?.()
5272
+ await waiter
5273
+
5274
+ expect(writes).toBe(1)
5275
+ expect(readSessionState('waiter-commit-first', h.env).accepted?.delivered_at).toBe(
5276
+ NOW,
5277
+ )
5278
+
5279
+ await hookRunCommand(
5280
+ h.deps,
5281
+ 'session-start',
5282
+ stdin({ session_id: 'waiter-commit-first' }),
5283
+ 'claude-code',
5284
+ )
5285
+ await runEscalationWaiter(context, {
5286
+ sessionId: 'waiter-commit-first',
5287
+ envelope: { session_id: 'waiter-commit-first' },
5288
+ route: {
5289
+ kind: 'inbox-socket',
5290
+ deliver: async () => {
5291
+ writes += 1
5292
+ return { acknowledgement: 'delivered' }
5293
+ },
5294
+ },
5295
+ processDeadlineAt: NOW + 120_000,
5296
+ })
5297
+ expect(writes).toBe(1)
5298
+ })
5299
+
4586
5300
  it('delivers an accepted answer once on a route that acknowledges its own write', async () => {
4587
5301
  const h = harness([])
4588
5302
  journaled(h, 'waiter-once')
@@ -4590,7 +5304,8 @@ describe('escalation waiter delivery seam', () => {
4590
5304
  const deliveries: string[] = []
4591
5305
  const route = {
4592
5306
  kind: 'inbox-socket' as const,
4593
- deliver: async (event: { context: string }) => {
5307
+ deliver: async (event: { context: string; commitDelivery(): boolean }) => {
5308
+ expect(event.commitDelivery()).toBe(true)
4594
5309
  deliveries.push(event.context)
4595
5310
  return {
4596
5311
  notes: ['posted the accepted answer'],
@@ -4607,7 +5322,7 @@ describe('escalation waiter delivery seam', () => {
4607
5322
  sessionId: 'waiter-once',
4608
5323
  envelope: { session_id: 'waiter-once', stop_hook_active: false },
4609
5324
  route,
4610
- processDeadlineAt: NOW + 480_000,
5325
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4611
5326
  })
4612
5327
  }
4613
5328
 
@@ -4616,6 +5331,25 @@ describe('escalation waiter delivery seam', () => {
4616
5331
  expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
4617
5332
  })
4618
5333
 
5334
+ it('does not settle a route that reports delivery without the SessionEnd commit', async () => {
5335
+ const h = harness([])
5336
+ journaled(h, 'waiter-uncommitted-route')
5337
+ const context = waiterContext(h)
5338
+
5339
+ const outcome = await runEscalationWaiter(context, {
5340
+ sessionId: 'waiter-uncommitted-route',
5341
+ envelope: { session_id: 'waiter-uncommitted-route' },
5342
+ route: {
5343
+ kind: 'inbox-socket',
5344
+ deliver: async () => ({ acknowledgement: 'delivered' }),
5345
+ },
5346
+ processDeadlineAt: NOW + 120_000,
5347
+ })
5348
+
5349
+ expect(readSessionState('waiter-uncommitted-route', h.env).accepted).toBeDefined()
5350
+ expect(outcome.notes.join('\n')).toContain('reported delivery without committing')
5351
+ })
5352
+
4619
5353
  it('does not spend the delivery cap on turns where nothing was handed over', async () => {
4620
5354
  // The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
4621
5355
  // where a liveness probe could not reach a busy session were enough to
@@ -4635,7 +5369,7 @@ describe('escalation waiter delivery seam', () => {
4635
5369
  kind: 'inbox-socket',
4636
5370
  deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
4637
5371
  },
4638
- processDeadlineAt: NOW + 480_000,
5372
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4639
5373
  })
4640
5374
  }
4641
5375
  // Still journaled after more holds than the delivery cap allows.
@@ -4647,12 +5381,13 @@ describe('escalation waiter delivery seam', () => {
4647
5381
  envelope: { session_id: sessionId, stop_hook_active: false },
4648
5382
  route: {
4649
5383
  kind: 'inbox-socket',
4650
- deliver: async () => {
5384
+ deliver: async (event) => {
5385
+ expect(event.commitDelivery()).toBe(true)
4651
5386
  handedOver += 1
4652
5387
  return { notes: [], acknowledgement: 'delivered' as const }
4653
5388
  },
4654
5389
  },
4655
- processDeadlineAt: NOW + 480_000,
5390
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4656
5391
  })
4657
5392
  expect(handedOver).toBe(1)
4658
5393
  })
@@ -4679,7 +5414,7 @@ describe('escalation waiter delivery seam', () => {
4679
5414
  return { notes: [], acknowledgement: 'held' as const }
4680
5415
  },
4681
5416
  },
4682
- processDeadlineAt: NOW + 480_000,
5417
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4683
5418
  })
4684
5419
  notes.push(...outcome.notes)
4685
5420
  }
@@ -4711,7 +5446,7 @@ describe('escalation waiter delivery seam', () => {
4711
5446
  throw new Error('socket unavailable')
4712
5447
  },
4713
5448
  },
4714
- processDeadlineAt: NOW + 480_000,
5449
+ processDeadlineAt: NOW + QUESTION_WAITER_CEILING_SECONDS * 1000,
4715
5450
  }),
4716
5451
  ).rejects.toThrow('socket unavailable')
4717
5452
 
@@ -4778,12 +5513,8 @@ describe('session state across a prompt', () => {
4778
5513
  })
4779
5514
  })
4780
5515
 
4781
- describe('a question outliving the waiter that pushed it', () => {
4782
- it('records when the server stops accepting, not when this owner stops waiting', async () => {
4783
- // These were the same number until the window became a setting, and taking
4784
- // the minimum capped every question at the waiter's ceiling: a day-long
4785
- // window was swept as stale about eight minutes later and retired, so the
4786
- // answer given over lunch met a closed question.
5516
+ describe('a question reaching the end of its answer window', () => {
5517
+ it('keeps the owner alive until the server stops accepting', async () => {
4787
5518
  const h = harness([])
4788
5519
  const aDayOut = NOW + 86_400_000
4789
5520
  h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
@@ -4793,15 +5524,13 @@ describe('a question outliving the waiter that pushed it', () => {
4793
5524
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
4794
5525
 
4795
5526
  const state = readSessionState('outlive', h.env)
4796
- const live = state.pending?.[0]
4797
- // Still live, still carrying the server's window — not the waiter's.
4798
- expect(live?.request_id).toBeDefined()
4799
- expect(live?.reply_deadline_at).toBe(aDayOut)
4800
- // Not closed and not retired: those are what capped it at eight minutes.
4801
- expect(state.retiring).toBeUndefined()
4802
- expect(h.recorder.closed).toEqual([])
4803
- // And the waiter still stopped at its own ceiling rather than waiting a day.
4804
- expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
5527
+ expect(state.pending).toBeUndefined()
5528
+ expect(state.retiring).toEqual([
5529
+ expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
5530
+ ])
5531
+ expect(h.recorder.closed).toHaveLength(1)
5532
+ expect(h.deps.now?.()).toBeGreaterThan(NOW + 8 * 60 * 1000)
5533
+ expect(h.deps.now?.()).toBeLessThanOrEqual(aDayOut)
4805
5534
  })
4806
5535
 
4807
5536
  it('still closes a question the server has genuinely stopped accepting', async () => {
@@ -4812,7 +5541,7 @@ describe('a question outliving the waiter that pushed it', () => {
4812
5541
 
4813
5542
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
4814
5543
 
4815
- // Its window closed inside the waiter's ceiling, so it is retired here
5544
+ // Its short window closes under the same owner, so it is retired here
4816
5545
  // rather than left for a turn that could never collect it.
4817
5546
  expect(readSessionState('short-window', h.env).pending).toBeUndefined()
4818
5547
  expect(h.recorder.closed.length).toBe(1)