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