@raidiant/notifai 0.4.0 → 0.5.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.
package/src/hooks.test.ts CHANGED
@@ -20,8 +20,10 @@ import type {
20
20
  SubmissionReceipt,
21
21
  SubmitNotificationRequestT,
22
22
  } from '@raidiant/notifai-protocol'
23
- import { describe, expect, it } from 'vitest'
23
+ import { describe, expect, it, vi } from 'vitest'
24
24
  import { ApiCallError, type ApiClient } from './client.js'
25
+ import { CLAUDE_POST_SEND_LIVENESS_MS, type ClaudeWakeAdapters } from './claude-wake.js'
26
+ import type { CodexWakeAdapters, CodexWakeObservation } from './codex-wake.js'
25
27
  import { readStdinWithTimeout } from './hook-input.js'
26
28
  import {
27
29
  activeLogPath,
@@ -43,7 +45,8 @@ import {
43
45
  clearSessionState,
44
46
  drainRetirements,
45
47
  drainOrphanRetirements,
46
- isUserAway,
48
+ runEscalationWaiter,
49
+ MAX_CONTINUATION_COUNT,
47
50
  orphanRetirements,
48
51
  pruneAbandonedSessions,
49
52
  releaseQuestionPush,
@@ -193,12 +196,7 @@ interface Harness {
193
196
 
194
197
  const NOW = 1_800_000_000_000
195
198
 
196
- /**
197
- * `idleSeconds` defaults to null — "this machine has no idle source" — so these
198
- * cases exercise the degraded path and stay independent of whether the person
199
- * running the suite is touching their own keyboard.
200
- */
201
- function harness(replies: ReplyView[] = [], idleSeconds: number | null = null): Harness {
199
+ function harness(replies: ReplyView[] = []): Harness {
202
200
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-'))
203
201
  const env: NodeJS.ProcessEnv = {
204
202
  XDG_CONFIG_HOME: path.join(root, 'config'),
@@ -233,7 +231,6 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
233
231
  cwd: root,
234
232
  clientFactory: () => fakeClient(recorder, replies),
235
233
  now: () => clock,
236
- idleSeconds: () => idleSeconds,
237
234
  sleep: async (milliseconds: number) => {
238
235
  clock += milliseconds
239
236
  },
@@ -373,52 +370,16 @@ function writeGlobalConfig(h: Harness, toml: string): void {
373
370
  writeFileSync(path.join(dir, 'config.toml'), toml)
374
371
  }
375
372
 
376
- function presenceGatedConfig(cwd: string, env: NodeJS.ProcessEnv) {
377
- const config = loadConfig({ cwd, env })
378
- config.require_idle = { value: true, source: 'global:test' }
379
- return config
380
- }
381
-
382
- /** Long enough ago to be away under the 120s default. */
373
+ /** Long enough ago that no test is accidentally sensitive to the stamp. */
383
374
  const AWAY = NOW - 600_000
384
- const PRESENT = NOW - 5_000
385
-
386
- describe('presence gate', () => {
387
- it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
388
- const { env, deps } = harness()
389
- const config = presenceGatedConfig(deps.cwd, env)
390
- expect(isUserAway({}, config, NOW, null)).toBe(false)
391
- })
392
-
393
- it('is away only once the configured silence has elapsed', () => {
394
- const { env, deps } = harness()
395
- const config = presenceGatedConfig(deps.cwd, env)
396
- expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
397
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
398
- })
399
-
400
- // The case that motivated this: "run the full test suite", then three
401
- // minutes of watching. Elapsed time alone said away; the machine knows better.
402
- it('keeps a user who is watching a long turn present, however long the turn ran', () => {
403
- const { env, deps } = harness()
404
- const config = presenceGatedConfig(deps.cwd, env)
405
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
406
- })
407
-
408
- // Found by a live Claude Code session: a spawned agent's session
409
- // always has a just-set last_prompt_at, so requiring session silence too meant
410
- // its FIRST question could never escalate — the "kick off agents and walk
411
- // away" case the feature is for.
412
- it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
413
- const { env, deps } = harness()
414
- const config = presenceGatedConfig(deps.cwd, env)
415
- expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
416
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
417
- })
418
375
 
419
- it('pushes a spawned session first question once the machine has gone quiet', async () => {
420
- const h = harness([reply({ text: 'Yes' })], 900)
421
- writeGlobalConfig(h, 'require_idle = true\n')
376
+ describe('pushing a registered question', () => {
377
+ // Found by a live Claude Code session: a spawned agent always has a
378
+ // just-set last_prompt_at, and the routing gate once read that as the user
379
+ // being present — so its FIRST question could never escalate, which is the
380
+ // "kick off agents and walk away" case the feature exists for.
381
+ it('pushes a freshly spawned session first question', async () => {
382
+ const h = harness([reply({ text: 'Yes' })])
422
383
  // Prompt 20s ago, exactly as a just-spawned agent has.
423
384
  writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
424
385
  registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
@@ -439,7 +400,7 @@ describe('presence gate', () => {
439
400
  })
440
401
 
441
402
  it('names the resolved project in the pushed question title', async () => {
442
- const h = harness([reply({ text: 'Yes' })], 900)
403
+ const h = harness([reply({ text: 'Yes' })])
443
404
  const projectDir = path.join(h.deps.cwd, '.notifai')
444
405
  mkdirSync(projectDir, { recursive: true })
445
406
  writeFileSync(
@@ -453,23 +414,6 @@ describe('presence gate', () => {
453
414
 
454
415
  expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
455
416
  })
456
-
457
- it('falls back to elapsed time where no idle source exists', () => {
458
- const { env, deps } = harness()
459
- const config = presenceGatedConfig(deps.cwd, env)
460
- expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
461
- })
462
-
463
- it('does not push a question to the phone while the user is at the keyboard', async () => {
464
- // Silent for ten minutes by the session clock, but active by the machine's.
465
- const h = harness([reply({ text: 'Yes' })], 2)
466
- writeGlobalConfig(h, 'require_idle = true\n')
467
- writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
468
- registerQuestion('idle1', h.env, { question: 'Ship it?' })
469
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
470
- expect(h.recorder.submitted).toHaveLength(0)
471
- expect(h.io.errLines.join('\n')).toContain('at the keyboard')
472
- })
473
417
  })
474
418
 
475
419
  /**
@@ -488,22 +432,31 @@ describe('what the hook leaves behind', () => {
488
432
  }
489
433
 
490
434
  it('records why a question was held, in a name a filter can match', async () => {
491
- const h = harness([], 2)
492
- writeGlobalConfig(h, 'require_idle = true\n')
493
- writeSessionState('held', h.env, { last_prompt_at: AWAY })
494
- registerQuestion('held', h.env, { question: 'Ship it?' })
435
+ const h = harness([])
436
+ writeSessionState('held', h.env, { accepted: undefined })
437
+ registerQuestion('held', h.env, { question: 'Ship it?' }, NOW)
438
+ // A continuation turn must not re-ask the question it is continuing from.
439
+ writeSessionState('held', h.env, {
440
+ ...readSessionState('held', h.env),
441
+ continuation: { answered_at: NOW + 1000, count: 1 },
442
+ })
495
443
 
496
- await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'held', cwd: h.deps.cwd }))
444
+ await hookRunCommand(
445
+ recording(h),
446
+ 'stop',
447
+ stdin({ session_id: 'held', cwd: h.deps.cwd, stop_hook_active: true }),
448
+ )
497
449
 
498
- const held = gates(h).find((record) => record.data?.['reason'] === 'user-present')
450
+ const held = gates(h).find((record) => record.data?.['reason'] === 'continuation-repeat')
499
451
  expect(held).toBeDefined()
500
- expect(held!.data).toMatchObject({ verdict: 'held', idle_seconds: 2, away_after_seconds: 120 })
452
+ expect(held!.data).toMatchObject({ verdict: 'held' })
453
+ expect(h.recorder.submitted).toHaveLength(0)
501
454
  })
502
455
 
503
456
  it('records the switch being off, which the user is deliberately never told', async () => {
504
457
  // Silent to the user by design, so from outside "you turned it off" and "a
505
458
  // bug ate my question" look identical. This is what tells them apart.
506
- const h = harness([], 600)
459
+ const h = harness([])
507
460
  const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
508
461
  mkdirSync(dir, { recursive: true })
509
462
  writeFileSync(path.join(dir, 'config.toml'), 'ask_notifications = false\n')
@@ -553,7 +506,7 @@ describe('what the hook leaves behind', () => {
553
506
  })
554
507
 
555
508
  it('persists an answer once while still reporting it to the harness', async () => {
556
- const h = harness([reply({ text: 'Allow exactly once' })], 900)
509
+ const h = harness([reply({ text: 'Allow exactly once' })])
557
510
  writeSessionState('answer-log', h.env, { last_prompt_at: AWAY })
558
511
  registerQuestion('answer-log', h.env, { question: 'Ship it?' })
559
512
 
@@ -565,12 +518,16 @@ describe('what the hook leaves behind', () => {
565
518
 
566
519
  const raw = readFileSync(activeLogPath(h.env), 'utf8')
567
520
  expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
568
- expect(readLogRecords(h.env, { event: ['hook.answer'] }).records).toHaveLength(1)
521
+ const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
522
+ expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
523
+ 'queued',
524
+ 'delivered',
525
+ ])
569
526
  expect(h.io.errLines.join('\n')).toContain('Allow exactly once')
570
527
  })
571
528
 
572
529
  it('records the push and ties it to the request id the server knows', async () => {
573
- const h = harness([], 600)
530
+ const h = harness([])
574
531
  const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
575
532
  mkdirSync(dir, { recursive: true })
576
533
  writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
@@ -589,7 +546,7 @@ describe('what the hook leaves behind', () => {
589
546
  })
590
547
 
591
548
  it('stores every request in a multi-question wait as a structured identity', async () => {
592
- const h = harness([], 900)
549
+ const h = harness([])
593
550
  writeSessionState('request-log', h.env, { last_prompt_at: AWAY })
594
551
  registerQuestion('request-log', h.env, { question: 'Ship it?' }, NOW)
595
552
  registerQuestion('request-log', h.env, { question: 'Deploy it?' }, NOW + 1)
@@ -649,7 +606,7 @@ describe('what the hook leaves behind', () => {
649
606
  })
650
607
 
651
608
  it('never writes the machine credential, whatever the hook did', async () => {
652
- const h = harness([], 600)
609
+ const h = harness([])
653
610
  const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
654
611
  mkdirSync(dir, { recursive: true })
655
612
  writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
@@ -671,23 +628,21 @@ describe('what the hook leaves behind', () => {
671
628
  * Presence is a precondition only while the user wants it to be, and switching
672
629
  * it off must not disturb the grace timer.
673
630
  */
674
- describe('presence gating is optional (require_idle)', () => {
675
- it('consults active typing only when presence gating is explicitly enabled', () => {
676
- // One second of idle: as present as it is possible to be.
677
- const ungated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
678
- expect(isUserAway({ last_prompt_at: NOW }, ungated, NOW, 1)).toBe(true)
679
-
680
- const gated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
681
- gated.require_idle = { value: true, source: 'global:test' }
682
- expect(isUserAway({ last_prompt_at: NOW }, gated, NOW, 1)).toBe(false)
683
- })
631
+ describe('terminal-first grace window', () => {
632
+ /** Registers a question asked `agoMs` ago. */
633
+ function pending(h: Harness, session: string, agoMs: number): void {
634
+ writeSessionState(session, h.env, { last_prompt_at: AWAY })
635
+ registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
636
+ }
684
637
 
685
- it('escalates immediately by default while the user is at the keyboard', async () => {
686
- const h = harness([], 1)
638
+ it('pushes as soon as the turn ends when no window is configured', async () => {
639
+ const h = harness([])
687
640
  let submittedAt: number | undefined
688
641
  h.recorder.beforeQuestionSubmit = () => {
689
642
  submittedAt = h.deps.now?.()
690
643
  }
644
+ // The user typed this very instant. It changes nothing: the waiter does
645
+ // not hold their terminal, so their being here is not a reason to wait.
691
646
  writeSessionState('present1', h.env, { last_prompt_at: NOW })
692
647
  registerQuestion('present1', h.env, { question: 'Ship it?' })
693
648
 
@@ -695,135 +650,73 @@ describe('presence gating is optional (require_idle)', () => {
695
650
 
696
651
  expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
697
652
  expect(submittedAt).toBe(NOW)
698
- expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
699
- })
700
-
701
- it('still gives the terminal its grace window first', async () => {
702
- // The two knobs are independent: not needing the user to leave does not
703
- // mean skipping the wait that offers the question to the terminal.
704
- const h = harness([], 1)
705
- writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 300\n')
706
- writeSessionState('present2', h.env, { last_prompt_at: NOW })
707
- registerQuestion('present2', h.env, { question: 'Ship it?' }, NOW)
708
-
709
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
710
-
711
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
712
- // The clock only advances when the hook sleeps, so this is proof the wait
713
- // actually happened rather than being skipped.
714
- expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
715
- })
716
-
717
- it('honours the grace window on a machine with no idle source at all', async () => {
718
- // With presence gating on, no idle source means refusing to wait
719
- // ('no-signal'). With it off there is nothing to watch for, so the timer
720
- // is just a timer and works everywhere.
721
- const h = harness([], null)
722
- writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 120\n')
723
- registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
724
-
725
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
726
-
727
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
728
- expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
729
- expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
730
653
  })
731
654
 
732
- it('is not a way to switch escalation on when the user has switched it off', async () => {
733
- // ask_notifications is the "do not reach me" switch and outranks this one;
734
- // wanting to be reachable while working is a different question entirely.
735
- const h = harness([], 1)
736
- writeGlobalConfig(h, 'require_idle = false\nask_notifications = false\n')
737
- registerQuestion('present4', h.env, { question: 'Ship it?' })
738
-
739
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
740
-
741
- expect(h.recorder.submitted).toHaveLength(0)
742
- })
743
- })
744
-
745
- describe('terminal-first grace window', () => {
746
- /** Registers a question asked `agoMs` ago, with the user long since silent. */
747
- function pending(h: Harness, session: string, agoMs: number): void {
748
- writeSessionState(session, h.env, { last_prompt_at: AWAY })
749
- registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
750
- }
751
-
752
655
  it('holds the question in the terminal until the window elapses', async () => {
753
- // Idle 900s: the user is gone, so the wait runs to completion rather than
754
- // being abandoned. Sleeps advance the virtual clock.
755
- const h = harness([reply({ text: 'Yes' })], 900)
656
+ const h = harness([reply({ text: 'Yes' })])
756
657
  writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
757
658
  pending(h, 'g1', 0)
758
659
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
759
660
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
760
- // Nothing was pushed before the explicitly configured 300s had passed.
661
+ // Sleeps advance the virtual clock, so this is proof the wait happened.
761
662
  expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
762
663
  })
763
664
 
764
665
  it('counts the window from when the question was sent, not from the turn end', async () => {
765
666
  // Asked 290s ago while the agent kept working: only 10s of wait remains.
766
- const h = harness([reply({ text: 'Yes' })], 900)
667
+ const h = harness([reply({ text: 'Yes' })])
668
+ writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
767
669
  pending(h, 'g2', 290_000)
768
670
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g2' }))
769
671
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
770
672
  expect(h.deps.now?.()).toBeLessThan(NOW + 60_000)
771
673
  })
772
674
 
773
- it('sends nothing if the user comes back to the keyboard during the window', async () => {
774
- let idle = 900
775
- const h = harness([reply({ text: 'Yes' })], 900)
776
- writeGlobalConfig(h, 'require_idle = true\n')
777
- // Machine goes active on the second poll: the user sat down.
778
- h.deps.idleSeconds = () => {
779
- const current = idle
780
- idle = 1
781
- return current
782
- }
783
- pending(h, 'g3', 0)
784
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g3' }))
785
- expect(h.recorder.submitted).toHaveLength(0)
786
- expect(h.io.errLines.join('\n')).toContain('came back')
675
+ it('waits the window out on a machine with no idle signal at all', async () => {
676
+ // The timer once refused to run without an idle source to watch, because
677
+ // it was holding a terminal it could not monitor. It holds nothing now.
678
+ const h = harness([])
679
+ writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
680
+ registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
681
+
682
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
683
+
684
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
685
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
787
686
  })
788
687
 
789
- it('refuses to hold a terminal it cannot monitor', async () => {
790
- // No idle source: waiting would block the prompt with no way to notice the
791
- // user returning, so it asks immediately instead.
792
- const h = harness([reply({ text: 'Yes' })], null)
793
- writeGlobalConfig(h, 'require_idle = true\n')
794
- pending(h, 'g4', 0)
795
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g4' }))
796
- expect(h.recorder.submitted.length).toBeGreaterThan(0)
797
- expect(h.deps.now?.()).toBe(NOW)
798
- expect(h.io.errLines.join('\n')).toContain('no idle signal')
688
+ it('is not a way to switch escalation on when the user has switched it off', async () => {
689
+ // ask_notifications is the "do not reach me" switch and outranks the timer.
690
+ const h = harness([])
691
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\nask_notifications = false\n')
692
+ registerQuestion('present4', h.env, { question: 'Ship it?' })
693
+
694
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
695
+
696
+ expect(h.recorder.submitted).toHaveLength(0)
799
697
  })
800
698
 
801
- it('never lets the window crowd out the reply wait past the hook budget', async () => {
802
- // Both preferences can be large, but the shared budget honors maximum
803
- // grace while retaining a protocol-valid answer window and scheduling
804
- // slack. Real timers do not wake on the exact requested millisecond.
805
- const h = harness([reply({ text: 'Yes' })], 900)
699
+ it('never lets the longest window crowd out a reply window the server accepts', async () => {
700
+ // The maximum grace a user can configure still has to leave the protocol's
701
+ // 60s minimum inside the waiter ceiling. Real timers do not wake on the
702
+ // exact requested millisecond, so the sleep deliberately overshoots.
703
+ const h = harness([reply({ text: 'Yes' })])
806
704
  const virtualSleep = h.deps.sleep!
807
705
  h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
808
- const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
809
- mkdirSync(dir, { recursive: true })
810
- writeFileSync(
811
- path.join(dir, 'config.toml'),
812
- 'ask_grace_seconds = 334\nhook_reply_timeout_seconds = 540\n',
813
- )
706
+ writeGlobalConfig(h, 'ask_grace_seconds = 360\n')
814
707
  pending(h, 'g5', 0)
815
708
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
816
709
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
817
- expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 334_000)
818
- expect(h.deps.now?.()).toBeLessThan(NOW + 335_000)
710
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
711
+ expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
819
712
  expect(
820
713
  h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
821
714
  ?.expires_in_seconds,
822
715
  ).toBeGreaterThanOrEqual(60)
823
716
  })
824
717
 
825
- it('reserves transport overrun and close time inside the whole Stop budget', async () => {
826
- const h = harness([], 900)
718
+ it('charges transport overrun and close time to the one waiter ceiling', async () => {
719
+ const h = harness([])
827
720
  const factory = h.deps.clientFactory
828
721
  h.deps.clientFactory = () => {
829
722
  const client = factory!()
@@ -855,7 +748,7 @@ describe('terminal-first grace window', () => {
855
748
  },
856
749
  } as ApiClient
857
750
  }
858
- writeGlobalConfig(h, 'ask_grace_seconds = 250\nhook_reply_timeout_seconds = 540\n')
751
+ writeGlobalConfig(h, 'ask_grace_seconds = 250\n')
859
752
  pending(h, 'transport-budget', 0)
860
753
 
861
754
  await hookRunCommand(h.deps, 'stop', async () => {
@@ -864,7 +757,10 @@ describe('terminal-first grace window', () => {
864
757
  return JSON.stringify({ session_id: 'transport-budget' })
865
758
  })
866
759
 
867
- expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
760
+ // The waiter's 480s ceiling is the last moment it will accept an answer;
761
+ // closing the window and retiring the card happens after it, which is what
762
+ // the blocking hosts' extra minute of declared headroom is there for.
763
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 540_000)
868
764
  expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
869
765
  })
870
766
  })
@@ -916,7 +812,7 @@ describe('nagging guards', () => {
916
812
  })
917
813
 
918
814
  it('delivers a new question registered during an answer continuation', async () => {
919
- const h = harness([reply({ text: 'First answer' })], 900)
815
+ const h = harness([reply({ text: 'First answer' })])
920
816
  writeSessionState('n3', h.env, { last_prompt_at: AWAY })
921
817
  registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
922
818
 
@@ -936,7 +832,7 @@ describe('nagging guards', () => {
936
832
  })
937
833
 
938
834
  it('stops chained questions at the consecutive continuation cap', async () => {
939
- const h = harness([], 900)
835
+ const h = harness([])
940
836
  writeSessionState('n4', h.env, {
941
837
  last_prompt_at: AWAY,
942
838
  continuation: { answered_at: NOW - 1, count: 3 },
@@ -949,11 +845,27 @@ describe('nagging guards', () => {
949
845
  expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
950
846
  expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
951
847
  })
848
+
849
+ it('starts the consecutive continuation count over when the user takes a turn', async () => {
850
+ // The cap bounds an agent running on its own. A human at the keyboard is
851
+ // the end of that chain, not another link in it.
852
+ const h = harness([])
853
+ writeSessionState('n5', h.env, {
854
+ continuation: { answered_at: NOW - 1, count: MAX_CONTINUATION_COUNT },
855
+ })
856
+
857
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'n5' }))
858
+
859
+ expect(readSessionState('n5', h.env).continuation).toEqual({
860
+ answered_at: NOW - 1,
861
+ count: 0,
862
+ })
863
+ })
952
864
  })
953
865
 
954
866
  describe('late answer collection', () => {
955
867
  it('collects an answer that arrived during the resumed model turn', async () => {
956
- const h = harness([reply({ text: 'Ship it' })], 900)
868
+ const h = harness([reply({ text: 'Ship it' })])
957
869
  writeSessionState('late-stop', h.env, {
958
870
  last_prompt_at: AWAY,
959
871
  pending: [{
@@ -979,7 +891,7 @@ describe('late answer collection', () => {
979
891
  })
980
892
 
981
893
  it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
982
- const h = harness([reply({ text: 'Hold' })], 900)
894
+ const h = harness([reply({ text: 'Hold' })])
983
895
  writeSessionState('late-prompt', h.env, {
984
896
  last_prompt_at: AWAY,
985
897
  pending: [{
@@ -1024,7 +936,7 @@ describe('late answer collection', () => {
1024
936
 
1025
937
  describe('OpenCode answer continuation', () => {
1026
938
  it('fails closed instead of accepting an answer the idle event cannot deliver', async () => {
1027
- const h = harness([reply({ text: 'Approve' })], 900)
939
+ const h = harness([reply({ text: 'Approve' })])
1028
940
  writeSessionState('open1', h.env, { last_prompt_at: AWAY })
1029
941
  registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
1030
942
 
@@ -1041,9 +953,9 @@ describe('OpenCode answer continuation', () => {
1041
953
  })
1042
954
  })
1043
955
 
1044
- describe('reply-wait presence monitoring', () => {
956
+ describe('the waiter owning one question to the end', () => {
1045
957
  it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
1046
- const h = harness([], 900)
958
+ const h = harness([])
1047
959
  const factory = h.deps.clientFactory
1048
960
  h.deps.clientFactory = () => {
1049
961
  const client = factory!()
@@ -1064,7 +976,6 @@ describe('reply-wait presence monitoring', () => {
1064
976
  },
1065
977
  } as ApiClient
1066
978
  }
1067
- writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
1068
979
  writeSessionState('fenced-reply', h.env, { last_prompt_at: AWAY })
1069
980
  registerQuestion('fenced-reply', h.env, { question: 'Deploy?' }, NOW)
1070
981
 
@@ -1077,7 +988,7 @@ describe('reply-wait presence monitoring', () => {
1077
988
  })
1078
989
 
1079
990
  it('preserves a silent question when the close fence cannot be proven', async () => {
1080
- const h = harness([], 900)
991
+ const h = harness([])
1081
992
  const factory = h.deps.clientFactory
1082
993
  h.deps.clientFactory = () => {
1083
994
  const client = factory!()
@@ -1088,7 +999,6 @@ describe('reply-wait presence monitoring', () => {
1088
999
  },
1089
1000
  } as ApiClient
1090
1001
  }
1091
- writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
1092
1002
  writeSessionState('unproven-close', h.env, { last_prompt_at: AWAY })
1093
1003
  registerQuestion('unproven-close', h.env, { question: 'Deploy?' }, NOW)
1094
1004
 
@@ -1101,7 +1011,7 @@ describe('reply-wait presence monitoring', () => {
1101
1011
  })
1102
1012
 
1103
1013
  it('recovers an accepted question after its submit response is lost', async () => {
1104
- const h = harness([], 900)
1014
+ const h = harness([])
1105
1015
  const factory = h.deps.clientFactory
1106
1016
  let acceptedRequestId: string | undefined
1107
1017
  h.deps.clientFactory = () => {
@@ -1140,7 +1050,7 @@ describe('reply-wait presence monitoring', () => {
1140
1050
  })
1141
1051
 
1142
1052
  it('replays the frozen intent when an ambiguous submit never reached the server', async () => {
1143
- const h = harness([], 900)
1053
+ const h = harness([])
1144
1054
  const factory = h.deps.clientFactory
1145
1055
  let firstAttempt: SubmitNotificationRequestT | undefined
1146
1056
  h.deps.clientFactory = () => {
@@ -1177,7 +1087,7 @@ describe('reply-wait presence monitoring', () => {
1177
1087
  })
1178
1088
 
1179
1089
  it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
1180
- const h = harness([], 900)
1090
+ const h = harness([])
1181
1091
  const factory = h.deps.clientFactory
1182
1092
  let polls = 0
1183
1093
  h.deps.clientFactory = () => {
@@ -1218,29 +1128,8 @@ describe('reply-wait presence monitoring', () => {
1218
1128
  })
1219
1129
  })
1220
1130
 
1221
- it('returns the terminal when the user comes back after the push', async () => {
1222
- const h = harness([], 900)
1223
- writeGlobalConfig(h, 'require_idle = true\n')
1224
- let checks = 0
1225
- h.deps.idleSeconds = () => {
1226
- checks += 1
1227
- return checks >= 4 ? 1 : 900
1228
- }
1229
- writeSessionState('returned', h.env, { last_prompt_at: AWAY })
1230
- registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
1231
-
1232
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
1233
-
1234
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
1235
- expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1236
- expect(readSessionState('returned', h.env).pending).toBeUndefined()
1237
- expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
1238
- expect(h.io.errLines.join('\n')).toContain('came back')
1239
- expect(h.io.errLines.join('\n')).toContain('retired before returning the terminal')
1240
- })
1241
-
1242
1131
  it('closes and retires silence at the absolute owner deadline', async () => {
1243
- const h = harness([], 900)
1132
+ const h = harness([])
1244
1133
  const factory = h.deps.clientFactory
1245
1134
  h.deps.clientFactory = () => {
1246
1135
  const client = factory!()
@@ -1254,7 +1143,6 @@ describe('reply-wait presence monitoring', () => {
1254
1143
  }),
1255
1144
  } as ApiClient
1256
1145
  }
1257
- writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
1258
1146
  writeSessionState('deadline', h.env, { last_prompt_at: AWAY })
1259
1147
  registerQuestion('deadline', h.env, { question: 'Deploy?' }, NOW)
1260
1148
 
@@ -1267,7 +1155,7 @@ describe('reply-wait presence monitoring', () => {
1267
1155
  })
1268
1156
 
1269
1157
  it('charges sequential submission latency to one deadline', async () => {
1270
- const h = harness([reply({ text: 'Done' })], 900)
1158
+ const h = harness([reply({ text: 'Done' })])
1271
1159
  writeSessionState('latency', h.env, { last_prompt_at: AWAY })
1272
1160
  registerQuestion('latency', h.env, { question: 'First?' }, NOW)
1273
1161
  registerQuestion('latency', h.env, { question: 'Second?' }, NOW)
@@ -1278,12 +1166,14 @@ describe('reply-wait presence monitoring', () => {
1278
1166
  const windows = h.recorder.submitted
1279
1167
  .filter((entry) => entry.draft.event === 'agent_question')
1280
1168
  .map((entry) => entry.draft.reply?.expires_in_seconds)
1281
- expect(windows).toEqual([395, 355])
1282
- expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 435_000)
1169
+ // Both windows end at the same absolute moment: the second question's is
1170
+ // shorter by exactly the 40s the first submission spent.
1171
+ expect(windows).toEqual([480, 440])
1172
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
1283
1173
  })
1284
1174
 
1285
1175
  it('re-arms the next Stop for an independently pending answer', async () => {
1286
- const h = harness([], 900)
1176
+ const h = harness([])
1287
1177
  const answers = new Map<string, ReplyView[]>([
1288
1178
  ['req_hook_1', [reply({ text: 'First answer' })]],
1289
1179
  ['req_hook_2', []],
@@ -1315,7 +1205,7 @@ describe('reply-wait presence monitoring', () => {
1315
1205
 
1316
1206
  describe('Cursor stop output', () => {
1317
1207
  it('fails closed when the invoking shell cannot name the exact conversation', async () => {
1318
- const h = harness([reply({ text: 'Ship it' })], 900)
1208
+ const h = harness([reply({ text: 'Ship it' })])
1319
1209
  writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
1320
1210
  registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
1321
1211
 
@@ -1491,7 +1381,7 @@ describe('several questions in flight', () => {
1491
1381
  }
1492
1382
 
1493
1383
  it('escalates every registered question in one pass, each as its own notification', async () => {
1494
- const h = harness([], 900)
1384
+ const h = harness([])
1495
1385
  writeSessionState('multi1', h.env, { last_prompt_at: AWAY })
1496
1386
  registerQuestion('multi1', h.env, { question: 'Ship it?' }, NOW)
1497
1387
  registerQuestion('multi1', h.env, { question: 'Deploy where?' }, NOW + 1)
@@ -1513,7 +1403,7 @@ describe('several questions in flight', () => {
1513
1403
  * handler; the harness flag only changes answer injection, not settlement.
1514
1404
  */
1515
1405
  it('settles a registered form into a durable request_id on the Codex Stop path', async () => {
1516
- const h = harness([], 900)
1406
+ const h = harness([])
1517
1407
  writeSessionState('codex-form', h.env, { last_prompt_at: AWAY })
1518
1408
  const built = buildQuestions(
1519
1409
  { form: JSON.stringify({
@@ -1549,7 +1439,7 @@ describe('several questions in flight', () => {
1549
1439
  })
1550
1440
 
1551
1441
  it('keeps a settled ask durable and collects the answer after a transient internal_error', async () => {
1552
- const h = harness([], 900)
1442
+ const h = harness([])
1553
1443
  let polls = 0
1554
1444
  const factory = h.deps.clientFactory
1555
1445
  h.deps.clientFactory = () => {
@@ -1588,7 +1478,7 @@ describe('several questions in flight', () => {
1588
1478
  })
1589
1479
 
1590
1480
  it('stops retrying and names a permanent rejection during the blocking multi-wait', async () => {
1591
- const h = harness([], 900)
1481
+ const h = harness([])
1592
1482
  let polls = 0
1593
1483
  const factory = h.deps.clientFactory
1594
1484
  h.deps.clientFactory = () => {
@@ -1615,7 +1505,7 @@ describe('several questions in flight', () => {
1615
1505
  })
1616
1506
 
1617
1507
  it('distinguishes a permanent late-poll rejection from transient trouble', async () => {
1618
- const h = harness([], 900)
1508
+ const h = harness([])
1619
1509
  let polls = 0
1620
1510
  const factory = h.deps.clientFactory
1621
1511
  h.deps.clientFactory = () => {
@@ -1655,7 +1545,7 @@ describe('several questions in flight', () => {
1655
1545
  })
1656
1546
 
1657
1547
  it('resumes with every answer that arrived, each tied to its question', async () => {
1658
- const h = harness([], 900)
1548
+ const h = harness([])
1659
1549
  repliesByRequest(
1660
1550
  h,
1661
1551
  new Map([
@@ -1681,7 +1571,7 @@ describe('several questions in flight', () => {
1681
1571
  })
1682
1572
 
1683
1573
  it('journals an accepted answer until a successor Stop proves delivery', async () => {
1684
- const h = harness([reply({ text: 'Ship it' })], 900)
1574
+ const h = harness([reply({ text: 'Ship it' })])
1685
1575
  writeSessionState('answer-handoff', h.env, {
1686
1576
  last_prompt_at: AWAY,
1687
1577
  pending: [
@@ -1724,7 +1614,7 @@ describe('several questions in flight', () => {
1724
1614
  })
1725
1615
 
1726
1616
  it('resumes with a partial answer and keeps the rest registered', async () => {
1727
- const h = harness([], 900)
1617
+ const h = harness([])
1728
1618
  const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
1729
1619
  repliesByRequest(h, byRequest)
1730
1620
  writeSessionState('multi3', h.env, { last_prompt_at: AWAY })
@@ -1885,14 +1775,15 @@ describe('hostile input', () => {
1885
1775
  expect(seen).toBe('https://test.notifai.invalid')
1886
1776
  })
1887
1777
 
1888
- it('clamps an out-of-range away threshold instead of trusting it', () => {
1778
+ it('clamps an out-of-range grace window instead of trusting it', () => {
1889
1779
  const h = harness()
1890
1780
  const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-bounds-'))
1891
1781
  mkdirSync(path.join(project, '.notifai'), { recursive: true })
1892
- writeFileSync(path.join(project, '.notifai', 'config.toml'), 'away_after_seconds = -1\n')
1782
+ writeFileSync(path.join(project, '.notifai', 'config.toml'), 'ask_grace_seconds = 99999\n')
1893
1783
  const config = loadConfig({ cwd: project, env: h.env })
1894
- // -1 would make someone who just typed count as absent.
1895
- expect(config.away_after_seconds.value).toBeGreaterThanOrEqual(5)
1784
+ // A committed repository file must not be able to consume the waiter's
1785
+ // whole ceiling and leave no window in which an answer is still accepted.
1786
+ expect(config.ask_grace_seconds.value).toBeLessThanOrEqual(360)
1896
1787
  })
1897
1788
  })
1898
1789
 
@@ -1924,7 +1815,7 @@ describe('ask registration', () => {
1924
1815
  })
1925
1816
 
1926
1817
  it('registers a multi-question form and pushes it as one set', async () => {
1927
- const h = harness([reply({ text: 'Yes' })], 900)
1818
+ const h = harness([reply({ text: 'Yes' })])
1928
1819
  writeSessionState('form1', h.env, { last_prompt_at: AWAY })
1929
1820
  const built = buildQuestions(
1930
1821
  { form: JSON.stringify({
@@ -1967,6 +1858,34 @@ describe('ask registration', () => {
1967
1858
  ])
1968
1859
  })
1969
1860
 
1861
+ it('relays the real registered identity, question text, and answer without trust or approval claims', async () => {
1862
+ const h = harness(
1863
+ [
1864
+ reply({
1865
+ text: 'BETA',
1866
+ answers: [{ question_id: 'rollout-option', choice_ids: ['beta'], text: null }],
1867
+ }),
1868
+ ],
1869
+ 900,
1870
+ )
1871
+ writeSessionState('framing1', h.env, { last_prompt_at: AWAY })
1872
+ const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
1873
+ expect(built.ok).toBe(true)
1874
+ if (!built.ok) return
1875
+ registerQuestion('framing1', h.env, {
1876
+ question: 'Which rollout option?',
1877
+ questions: built.questions,
1878
+ })
1879
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'framing1' }))
1880
+
1881
+ const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
1882
+ expect(decision.decision).toBe('block')
1883
+ expect(decision.reason).toContain('question_id rollout-option')
1884
+ expect(decision.reason).toContain('"Which rollout option?"')
1885
+ expect(decision.reason).toContain('"BETA"')
1886
+ expect(decision.reason).not.toMatch(/trusted|urgent|permission|approval/i)
1887
+ })
1888
+
1970
1889
  it('acts on the latest reply when answers conflict, because it is a correction', async () => {
1971
1890
  const h = harness(
1972
1891
  [
@@ -2132,7 +2051,7 @@ describe('user-prompt-submit hook', () => {
2132
2051
  })
2133
2052
 
2134
2053
  it('preserves a delivered question through a second ask and the prompt transition', async () => {
2135
- const h = harness([], 900)
2054
+ const h = harness([])
2136
2055
  const live = {
2137
2056
  question: 'Ship it?',
2138
2057
  request_id: 'req_live',
@@ -2231,7 +2150,7 @@ describe('user-prompt-submit hook', () => {
2231
2150
  })
2232
2151
 
2233
2152
  it('retires as done/answered when the answer came from a device', async () => {
2234
- const h = harness([reply({ text: 'Yes' })], 900)
2153
+ const h = harness([reply({ text: 'Yes' })])
2235
2154
  registerQuestion('s16', h.env, { question: 'Ship it?' })
2236
2155
 
2237
2156
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
@@ -2442,7 +2361,7 @@ describe('telling concurrent agents apart', () => {
2442
2361
  // The hook has always known session_id and never passed it on, so two
2443
2362
  // agents in separate worktrees produced identical notifications and the
2444
2363
  // user could answer the wrong one's question.
2445
- const h = harness([], 900)
2364
+ const h = harness([])
2446
2365
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2447
2366
 
2448
2367
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
@@ -2451,7 +2370,7 @@ describe('telling concurrent agents apart', () => {
2451
2370
  })
2452
2371
 
2453
2372
  it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
2454
- const h = harness([reply({ text: 'Yes' })], 900)
2373
+ const h = harness([reply({ text: 'Yes' })])
2455
2374
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2456
2375
 
2457
2376
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
@@ -2466,7 +2385,7 @@ describe('telling concurrent agents apart', () => {
2466
2385
  })
2467
2386
 
2468
2387
  it('prefers a name the user chose over the harness UUID', async () => {
2469
- const h = harness([], 900)
2388
+ const h = harness([])
2470
2389
  h.env['NOTIFAI_SESSION'] = 'migration-worktree'
2471
2390
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2472
2391
 
@@ -2477,44 +2396,19 @@ describe('telling concurrent agents apart', () => {
2477
2396
  })
2478
2397
 
2479
2398
  describe('clock jumps', () => {
2480
- const config = presenceGatedConfig('/nowhere', {})
2481
-
2482
- it('does not hijack a terminal because the clock jumped forward', () => {
2483
- // NTP correction or a VM resume moves `now` without any time passing for
2484
- // the person sitting at the keyboard. Without an idle source there is
2485
- // nothing to check the delta against, so a huge one is not evidence.
2486
- const state = { last_prompt_at: NOW - 400 * 24 * 3600 * 1000 }
2487
- expect(isUserAway(state, config, NOW, null)).toBe(false)
2488
- })
2489
-
2490
- it('does not read a backward jump as the user being present either', () => {
2491
- // A negative delta is nonsense, not freshness. It resolves the same way:
2492
- // no evidence, so leave the terminal alone.
2493
- expect(isUserAway({ last_prompt_at: NOW + 60_000 }, config, NOW, null)).toBe(false)
2494
- })
2495
-
2496
- it('still escalates on an ordinary long silence', () => {
2497
- expect(isUserAway({ last_prompt_at: NOW - 3600_000 }, config, NOW, null)).toBe(true)
2498
- })
2499
-
2500
- it('lets the OS idle signal decide regardless of the wall clock', () => {
2501
- // The idle probe measures elapsed time directly, so it is unaffected — and
2502
- // it outranks the proxy anyway.
2503
- const nonsense = { last_prompt_at: NOW + 999_999_999 }
2504
- expect(isUserAway(nonsense, config, NOW, 900)).toBe(true)
2505
- expect(isUserAway(nonsense, config, NOW, 1)).toBe(false)
2506
- })
2507
-
2508
- it('does not let a stamp from the future hold the terminal past the budget', async () => {
2509
- // asked_at is wall-clock too. A future stamp used to make the grace window
2510
- // unreachable, blocking Stop until the harness killed it.
2511
- const h = harness([], 900)
2399
+ it('does not let a stamp from the future consume the whole waiter ceiling', async () => {
2400
+ // `asked_at` is wall-clock, and an NTP correction or VM resume can move it.
2401
+ // A stamp thirty days out would otherwise make the grace window unreachable
2402
+ // and leave nothing of the ceiling for an answer to arrive in.
2403
+ const h = harness([])
2404
+ writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
2512
2405
  registerQuestion('sess-jump', h.env, { question: 'Ship it?' }, NOW + 30 * 24 * 3600 * 1000)
2513
2406
 
2514
2407
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-jump' }))
2515
2408
 
2516
- // It escalated rather than spinning: the window restarted from now.
2517
2409
  expect(h.recorder.submitted.length).toBeGreaterThan(0)
2410
+ // The window ran from now, not from the impossible stamp.
2411
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
2518
2412
  })
2519
2413
  })
2520
2414
 
@@ -2755,7 +2649,7 @@ describe('two hooks racing one question', () => {
2755
2649
  })
2756
2650
 
2757
2651
  it('sends one notification when a second Stop arrives mid-flight', async () => {
2758
- const h = harness([], 900)
2652
+ const h = harness([])
2759
2653
  writeSessionState('race5', h.env, { last_prompt_at: AWAY })
2760
2654
  registerQuestion('race5', h.env, { question: 'Ship it?' })
2761
2655
  // Standing in for the other process: the claim is already held.
@@ -2769,7 +2663,7 @@ describe('two hooks racing one question', () => {
2769
2663
 
2770
2664
  it('resumes the agent once when two Stops collect the same late answer', async () => {
2771
2665
  const answers: ReplyView[] = []
2772
- const h = harness(answers, 900)
2666
+ const h = harness(answers)
2773
2667
  writeSessionState('race-late-answer', h.env, {
2774
2668
  last_prompt_at: AWAY,
2775
2669
  pending: [
@@ -2816,7 +2710,7 @@ describe('two hooks racing one question', () => {
2816
2710
 
2817
2711
  describe('question registration racing a Stop submission', () => {
2818
2712
  it('keeps the new question when the older owner expires', async () => {
2819
- const h = harness([], 900)
2713
+ const h = harness([])
2820
2714
  writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
2821
2715
  registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
2822
2716
  h.recorder.beforeQuestionSubmit = () => {
@@ -2839,7 +2733,7 @@ describe('question registration racing a Stop submission', () => {
2839
2733
  })
2840
2734
 
2841
2735
  it('keeps the newer question when the older in-flight question receives an answer', async () => {
2842
- const h = harness([reply({ text: 'Old answer' })], 900)
2736
+ const h = harness([reply({ text: 'Old answer' })])
2843
2737
  writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
2844
2738
  registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
2845
2739
  h.recorder.beforeQuestionSubmit = () => {
@@ -2855,3 +2749,599 @@ describe('question registration racing a Stop submission', () => {
2855
2749
  expect(state.accepted?.answers[0]?.reply.text).toBe('Old answer')
2856
2750
  })
2857
2751
  })
2752
+
2753
+ describe('Claude Code Stop wake route', () => {
2754
+ function claudeWake(status: 'idle' | 'busy' = 'idle'): ClaudeWakeAdapters & {
2755
+ sent: string[]
2756
+ resumed: string[]
2757
+ sleeps: number[]
2758
+ } {
2759
+ const sent: string[] = []
2760
+ const resumed: string[] = []
2761
+ const sleeps: number[] = []
2762
+ return {
2763
+ sent,
2764
+ resumed,
2765
+ sleeps,
2766
+ async listAgents() {
2767
+ return [
2768
+ {
2769
+ pid: 12345,
2770
+ sessionId: 'claude-route',
2771
+ startedAt: NOW,
2772
+ status,
2773
+ },
2774
+ ]
2775
+ },
2776
+ readDescriptor() {
2777
+ return {
2778
+ pid: 12345,
2779
+ sessionId: 'claude-route',
2780
+ cwd: '/tmp/claude-route',
2781
+ startedAt: NOW,
2782
+ procStart: 'Wed Aug 12 08:17:53 2026',
2783
+ version: '2.1.228',
2784
+ peerProtocol: 1,
2785
+ messagingSocketPath: '/tmp/cc-socks/12345.sock',
2786
+ status,
2787
+ }
2788
+ },
2789
+ async sendSocket(_socketPath, line) {
2790
+ sent.push(line)
2791
+ },
2792
+ async resume(_sessionId, _cwd, context) {
2793
+ resumed.push(context)
2794
+ },
2795
+ async sleep(milliseconds) {
2796
+ sleeps.push(milliseconds)
2797
+ },
2798
+ }
2799
+ }
2800
+
2801
+ it('routes an accepted answer through the Claude inbox instead of hook stdout', async () => {
2802
+ const h = harness([reply({ text: 'BETA' })])
2803
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
2804
+ writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
2805
+ const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
2806
+ expect(built.ok).toBe(true)
2807
+ if (!built.ok) return
2808
+ registerQuestion('claude-route', h.env, {
2809
+ question: 'Which rollout option?',
2810
+ questions: built.questions,
2811
+ })
2812
+ const wake = claudeWake()
2813
+
2814
+ await hookRunCommand(
2815
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
2816
+ 'stop',
2817
+ stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
2818
+ 'claude-code',
2819
+ )
2820
+
2821
+ expect(h.io.outLines).toEqual([])
2822
+ expect(wake.sent).toHaveLength(1)
2823
+ const message = JSON.parse(wake.sent[0]!) as {
2824
+ type: string
2825
+ message: { role: string; content: string }
2826
+ }
2827
+ expect(message).toMatchObject({ type: 'user', message: { role: 'user' } })
2828
+ expect(message.message.content).toContain('question_id')
2829
+ expect(message.message.content).toContain('"Which rollout option?"')
2830
+ expect(message.message.content).toContain('"BETA"')
2831
+ expect(message.message.content).not.toMatch(/trusted|urgent|permission|approval/i)
2832
+ expect(wake.sleeps).toEqual([CLAUDE_POST_SEND_LIVENESS_MS])
2833
+ expect(readSessionState('claude-route', h.env).accepted).toBeDefined()
2834
+ })
2835
+
2836
+ /** One answer already accepted and journaled, ready for the next Stop. */
2837
+ function journaledAnswer(
2838
+ h: Harness,
2839
+ options: { recordedAt?: number; deliveredAt?: number } = {},
2840
+ ): void {
2841
+ const recordedAt = options.recordedAt ?? NOW
2842
+ writeSessionState('claude-route', h.env, {
2843
+ ...readSessionState('claude-route', h.env),
2844
+ accepted: {
2845
+ ...(options.deliveredAt === undefined
2846
+ ? {}
2847
+ : { delivered_at: options.deliveredAt, delivered_route: 'inbox-socket' }),
2848
+ answers: [
2849
+ {
2850
+ pending: {
2851
+ question: 'Which rollout option?',
2852
+ request_id: 'req_existing',
2853
+ collapse_key: 'question-existing',
2854
+ device_ids: ['dev_iphone'],
2855
+ },
2856
+ reply: reply({ text: 'BETA' }),
2857
+ replies: [reply({ text: 'BETA' })],
2858
+ },
2859
+ ],
2860
+ remaining: 0,
2861
+ recorded_at: recordedAt,
2862
+ },
2863
+ })
2864
+ }
2865
+
2866
+ it('settles a socket delivery so the woken turn cannot redeliver the answer', async () => {
2867
+ // The exact device-proof failure. A socket delivery starts a brand-new turn
2868
+ // rather than continuing this one, so the Stop that ends the woken turn
2869
+ // reports stop_hook_active=false — for ever. While that flag was the only
2870
+ // acknowledgement, the journal never settled and every turn-end delivered
2871
+ // the same answer again: 250 times, in the transcript that found this.
2872
+ const h = harness([])
2873
+ journaledAnswer(h)
2874
+ const wake = claudeWake()
2875
+ const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
2876
+ const stop = (stopHookActive: boolean) =>
2877
+ hookRunCommand(
2878
+ deps,
2879
+ 'stop',
2880
+ stdin({
2881
+ session_id: 'claude-route',
2882
+ cwd: '/tmp/claude-route',
2883
+ stop_hook_active: stopHookActive,
2884
+ }),
2885
+ 'claude-code',
2886
+ )
2887
+
2888
+ await stop(false)
2889
+
2890
+ expect(wake.sent).toHaveLength(1)
2891
+ const delivered = readSessionState('claude-route', h.env).accepted
2892
+ expect(delivered?.delivered_route).toBe('inbox-socket')
2893
+ expect(typeof delivered?.delivered_at).toBe('number')
2894
+
2895
+ // Three more turn-ends of the woken session, none of them a continuation.
2896
+ await stop(false)
2897
+ await stop(false)
2898
+ await stop(false)
2899
+
2900
+ expect(wake.sent).toHaveLength(1)
2901
+ expect(wake.resumed).toEqual([])
2902
+ expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
2903
+ // Settled, not merely dropped: the answered question is retired truthfully.
2904
+ expect(
2905
+ h.recorder.submitted.find(
2906
+ (entry) =>
2907
+ entry.draft.event === 'question_retired' &&
2908
+ entry.draft.lifecycle?.retires_request_id === 'req_existing',
2909
+ )?.draft.lifecycle?.state,
2910
+ ).toBe('answered')
2911
+ })
2912
+
2913
+ it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
2914
+ // The chained loop the cap exists for, on a route that starts new turns.
2915
+ // Keyed to stop_hook_active the cap could never fire here at all.
2916
+ const h = harness([])
2917
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
2918
+ writeSessionState('claude-route', h.env, {
2919
+ last_prompt_at: AWAY,
2920
+ continuation: { answered_at: NOW - 2000, count: MAX_CONTINUATION_COUNT - 1 },
2921
+ })
2922
+ journaledAnswer(h, { recordedAt: NOW - 1000, deliveredAt: NOW - 900 })
2923
+ // The woken turn asked a genuinely new question before it ended.
2924
+ registerQuestion('claude-route', h.env, { question: 'And after that?' }, NOW)
2925
+ const wake = claudeWake()
2926
+
2927
+ await hookRunCommand(
2928
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
2929
+ 'stop',
2930
+ stdin({
2931
+ session_id: 'claude-route',
2932
+ cwd: '/tmp/claude-route',
2933
+ stop_hook_active: false,
2934
+ }),
2935
+ 'claude-code',
2936
+ )
2937
+
2938
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
2939
+ [],
2940
+ )
2941
+ expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
2942
+ expect(h.io.errLines.join('\n')).toContain(
2943
+ `continuation limit (${MAX_CONTINUATION_COUNT}) reached`,
2944
+ )
2945
+ })
2946
+
2947
+ it('uses the accepted journal as the loop guard after a socket wake fires Stop again', async () => {
2948
+ const h = harness([])
2949
+ writeSessionState('claude-route', h.env, {
2950
+ accepted: {
2951
+ answers: [
2952
+ {
2953
+ pending: {
2954
+ question: 'Which rollout option?',
2955
+ request_id: 'req_existing',
2956
+ collapse_key: 'question-existing',
2957
+ device_ids: ['dev_iphone'],
2958
+ },
2959
+ reply: reply({ text: 'BETA' }),
2960
+ replies: [reply({ text: 'BETA' })],
2961
+ },
2962
+ ],
2963
+ remaining: 0,
2964
+ recorded_at: NOW,
2965
+ },
2966
+ })
2967
+ const wake = claudeWake()
2968
+
2969
+ await hookRunCommand(
2970
+ { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
2971
+ 'stop',
2972
+ stdin({
2973
+ session_id: 'claude-route',
2974
+ cwd: '/tmp/claude-route',
2975
+ stop_hook_active: true,
2976
+ }),
2977
+ 'claude-code',
2978
+ )
2979
+
2980
+ expect(wake.sent).toEqual([])
2981
+ expect(wake.resumed).toEqual([])
2982
+ expect(h.io.outLines).toEqual([])
2983
+ expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
2984
+ })
2985
+ })
2986
+
2987
+ describe('Codex Stop wake route', () => {
2988
+ const CODEX_THREAD = '019ff69d-a07f-7161-ab6e-bd06b3b93c8e'
2989
+
2990
+ function codexWake(
2991
+ options: { sourceAlive?: boolean; probe?: CodexWakeObservation } = {},
2992
+ ): CodexWakeAdapters & { resumed: string[]; probed: string[] } {
2993
+ const resumed: string[] = []
2994
+ const probed: string[] = []
2995
+ return {
2996
+ resumed,
2997
+ probed,
2998
+ probeThreadWriter(lockPath) {
2999
+ probed.push(lockPath)
3000
+ return options.probe ?? { state: 'stopped' }
3001
+ },
3002
+ sourceAlive() {
3003
+ return options.sourceAlive ?? true
3004
+ },
3005
+ async resume(_threadId, _cwd, context) {
3006
+ resumed.push(context)
3007
+ },
3008
+ }
3009
+ }
3010
+
3011
+ function journaledAnswer(h: Harness): void {
3012
+ writeSessionState(CODEX_THREAD, h.env, {
3013
+ accepted: {
3014
+ answers: [
3015
+ {
3016
+ pending: {
3017
+ question: 'Which rollout option?',
3018
+ request_id: 'req_existing',
3019
+ collapse_key: 'question-existing',
3020
+ device_ids: ['dev_iphone'],
3021
+ },
3022
+ reply: reply({ text: 'BETA' }),
3023
+ replies: [reply({ text: 'BETA' })],
3024
+ },
3025
+ ],
3026
+ remaining: 0,
3027
+ recorded_at: NOW,
3028
+ },
3029
+ })
3030
+ }
3031
+
3032
+ it('continues the held turn with decision:block when the answer arrives during the hold', async () => {
3033
+ const h = harness([reply({ text: 'BETA' })])
3034
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
3035
+ writeSessionState(CODEX_THREAD, h.env, { last_prompt_at: AWAY })
3036
+ const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
3037
+ expect(built.ok).toBe(true)
3038
+ if (!built.ok) return
3039
+ registerQuestion(CODEX_THREAD, h.env, {
3040
+ question: 'Which rollout option?',
3041
+ questions: built.questions,
3042
+ })
3043
+ const wake = codexWake()
3044
+
3045
+ await hookRunCommand(
3046
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3047
+ 'stop',
3048
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3049
+ 'codex',
3050
+ )
3051
+
3052
+ expect(h.io.outLines).toHaveLength(1)
3053
+ const decision = JSON.parse(h.io.outLines[0]!) as { decision: string; reason: string }
3054
+ expect(decision.decision).toBe('block')
3055
+ expect(decision.reason).toContain('"BETA"')
3056
+ // The default route owes the thread-writer lock nothing: a live Codex is
3057
+ // reading this stdout, and a probe could only take the answer away.
3058
+ expect(wake.probed).toEqual([])
3059
+ expect(wake.resumed).toEqual([])
3060
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
3061
+ })
3062
+
3063
+ it('replays an answer journaled after the hold on the next Stop', async () => {
3064
+ const h = harness([])
3065
+ journaledAnswer(h)
3066
+ const wake = codexWake()
3067
+
3068
+ await hookRunCommand(
3069
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3070
+ 'stop',
3071
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3072
+ 'codex',
3073
+ )
3074
+
3075
+ expect(h.io.outLines).toHaveLength(1)
3076
+ expect(JSON.parse(h.io.outLines[0]!)).toMatchObject({ decision: 'block' })
3077
+ // Still journaled: only a successor Stop proves the continued turn ran.
3078
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
3079
+ })
3080
+
3081
+ it('cold-resumes the journaled answer when the Codex process is gone and no writer holds the thread', async () => {
3082
+ const h = harness([])
3083
+ journaledAnswer(h)
3084
+ const wake = codexWake({ sourceAlive: false, probe: { state: 'stopped' } })
3085
+
3086
+ await hookRunCommand(
3087
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3088
+ 'stop',
3089
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3090
+ 'codex',
3091
+ )
3092
+
3093
+ expect(h.io.outLines).toEqual([])
3094
+ expect(wake.resumed).toHaveLength(1)
3095
+ expect(wake.resumed[0]).toContain('"BETA"')
3096
+ expect(wake.probed).toHaveLength(2)
3097
+ })
3098
+
3099
+ it('holds the answer rather than resuming a thread a live writer owns', async () => {
3100
+ const h = harness([])
3101
+ journaledAnswer(h)
3102
+ const wake = codexWake({ sourceAlive: false, probe: { state: 'live' } })
3103
+
3104
+ await hookRunCommand(
3105
+ { ...h.deps, codexWake: wake, codexSourcePid: 12345 },
3106
+ 'stop',
3107
+ stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
3108
+ 'codex',
3109
+ )
3110
+
3111
+ expect(h.io.outLines).toEqual([])
3112
+ expect(wake.resumed).toEqual([])
3113
+ expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
3114
+ expect(h.io.errLines.join('\n')).toContain('holding the accepted answer for the next turn')
3115
+ })
3116
+ })
3117
+
3118
+ describe('escalation waiter delivery seam', () => {
3119
+ function waiterContext(h: Harness) {
3120
+ const client = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer test')
3121
+ return {
3122
+ client,
3123
+ config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
3124
+ env: h.env,
3125
+ now: h.deps.now!,
3126
+ sleep: h.deps.sleep!,
3127
+ waitForFirstReply: async (requestId: string, timeoutSeconds: number) => {
3128
+ const response = await client.replies(requestId, {
3129
+ waitSeconds: timeoutSeconds,
3130
+ afterSeq: 0,
3131
+ })
3132
+ return { replies: response.replies, timedOut: response.replies.length === 0 }
3133
+ },
3134
+ harness: 'codex' as const,
3135
+ }
3136
+ }
3137
+
3138
+ it('waits through grace, routes the fenced answer, and holds the claim until delivery settles', async () => {
3139
+ const h = harness([reply({ text: 'Ship it' })])
3140
+ writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
3141
+ writeSessionState('waiter-route', h.env, { last_prompt_at: AWAY })
3142
+ registerQuestion('waiter-route', h.env, { question: 'Ship it?' }, NOW)
3143
+
3144
+ const context = waiterContext(h)
3145
+ const deliveries: Array<{ route: string; context: string }> = []
3146
+ let claimHeldDuringDelivery = false
3147
+ const deliver = vi.fn(async (event: { context: string }) => {
3148
+ deliveries.push({ route: 'hook-continuation', context: event.context })
3149
+ claimHeldDuringDelivery = !claimQuestionPush('waiter-route', h.env)
3150
+ return { stdout: JSON.stringify({ decision: 'block', reason: event.context }) }
3151
+ })
3152
+
3153
+ const outcome = await runEscalationWaiter(context, {
3154
+ sessionId: 'waiter-route',
3155
+ envelope: { session_id: 'waiter-route' },
3156
+ route: { kind: 'hook-continuation', deliver },
3157
+ processDeadlineAt: NOW + 480_000,
3158
+ })
3159
+
3160
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
3161
+ expect(deliver).toHaveBeenCalledTimes(1)
3162
+ expect(deliver).toHaveBeenCalledWith(
3163
+ expect.objectContaining({ journal_recorded_at: expect.any(Number) }),
3164
+ )
3165
+ expect(deliveries).toEqual([
3166
+ { route: 'hook-continuation', context: expect.stringContaining('Ship it') },
3167
+ ])
3168
+ expect(claimHeldDuringDelivery).toBe(true)
3169
+ expect(JSON.parse(outcome.stdout ?? '{}')).toMatchObject({
3170
+ decision: 'block',
3171
+ reason: expect.stringContaining('Ship it'),
3172
+ })
3173
+ expect(claimQuestionPush('waiter-route', h.env)).toBe(true)
3174
+ releaseQuestionPush('waiter-route', h.env)
3175
+ })
3176
+
3177
+ it('races a second Stop while grace, polling, fencing, and route delivery are still owned', async () => {
3178
+ const h = harness([])
3179
+ writeGlobalConfig(h, 'ask_grace_seconds = 10\n')
3180
+ writeSessionState('waiter-race-lifetime', h.env, { last_prompt_at: AWAY })
3181
+ registerQuestion('waiter-race-lifetime', h.env, { question: 'Ship it?' }, NOW)
3182
+ const context = waiterContext(h)
3183
+ const phases: string[] = []
3184
+
3185
+ let waitingForReply = false
3186
+ context.sleep = async (milliseconds: number) => {
3187
+ if (!waitingForReply) {
3188
+ phases.push('grace')
3189
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3190
+ }
3191
+ h.advanceClock(milliseconds)
3192
+ }
3193
+ context.waitForFirstReply = async () => {
3194
+ waitingForReply = true
3195
+ phases.push('reply-wait')
3196
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3197
+ return { replies: [reply({ text: 'Ship it' })], timedOut: false }
3198
+ }
3199
+ const closeReplies = context.client.closeReplies.bind(context.client)
3200
+ context.client.closeReplies = async (requestId) => {
3201
+ phases.push('close-fence')
3202
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3203
+ return closeReplies(requestId)
3204
+ }
3205
+
3206
+ await runEscalationWaiter(context, {
3207
+ sessionId: 'waiter-race-lifetime',
3208
+ envelope: { session_id: 'waiter-race-lifetime' },
3209
+ route: {
3210
+ kind: 'hook-continuation',
3211
+ deliver: async (event) => {
3212
+ phases.push('delivery')
3213
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
3214
+ return { stdout: JSON.stringify({ decision: 'block', reason: event.context }), notes: [] }
3215
+ },
3216
+ },
3217
+ processDeadlineAt: NOW + 480_000,
3218
+ })
3219
+
3220
+ expect(phases.filter((phase) => phase === 'grace').length).toBeGreaterThan(0)
3221
+ expect(phases.filter((phase) => phase !== 'grace')).toEqual([
3222
+ 'reply-wait',
3223
+ 'close-fence',
3224
+ 'delivery',
3225
+ ])
3226
+ expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(true)
3227
+ releaseQuestionPush('waiter-race-lifetime', h.env)
3228
+ })
3229
+
3230
+ /** One accepted answer on the journal, whatever route is about to own it. */
3231
+ function journaled(h: Harness, sessionId: string): void {
3232
+ writeSessionState(sessionId, h.env, {
3233
+ accepted: {
3234
+ answers: [
3235
+ {
3236
+ pending: {
3237
+ question: 'Ship it?',
3238
+ request_id: 'req_existing',
3239
+ collapse_key: 'question-existing',
3240
+ device_ids: ['dev_iphone'],
3241
+ },
3242
+ reply: reply({ text: 'Ship it' }),
3243
+ replies: [reply({ text: 'Ship it' })],
3244
+ },
3245
+ ],
3246
+ remaining: 0,
3247
+ recorded_at: NOW,
3248
+ },
3249
+ })
3250
+ }
3251
+
3252
+ it('delivers an accepted answer once on a route that acknowledges its own write', async () => {
3253
+ const h = harness([])
3254
+ journaled(h, 'waiter-once')
3255
+ const context = waiterContext(h)
3256
+ const deliveries: string[] = []
3257
+ const route = {
3258
+ kind: 'inbox-socket' as const,
3259
+ deliver: async (event: { context: string }) => {
3260
+ deliveries.push(event.context)
3261
+ return {
3262
+ notes: ['posted the accepted answer'],
3263
+ log: { route: 'inbox-socket', stage: 'delivered' },
3264
+ acknowledgement: 'delivered' as const,
3265
+ }
3266
+ },
3267
+ }
3268
+
3269
+ // Five turn-ends, none of them a harness continuation — the shape of every
3270
+ // route that wakes a new turn instead of continuing this one.
3271
+ for (let pass = 0; pass < 5; pass += 1) {
3272
+ await runEscalationWaiter(context, {
3273
+ sessionId: 'waiter-once',
3274
+ envelope: { session_id: 'waiter-once', stop_hook_active: false },
3275
+ route,
3276
+ processDeadlineAt: NOW + 480_000,
3277
+ })
3278
+ }
3279
+
3280
+ expect(deliveries).toHaveLength(1)
3281
+ expect(deliveries[0]).toContain('Ship it')
3282
+ expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
3283
+ })
3284
+
3285
+ it('bounds deliveries by the continuation cap on every route', async () => {
3286
+ // A route that never acknowledges must still stop. The cap lives where all
3287
+ // routes pass, so no future route can be the one it does not cover.
3288
+ for (const kind of ['inbox-socket', 'hook-continuation'] as const) {
3289
+ const h = harness([])
3290
+ const sessionId = `waiter-cap-${kind}`
3291
+ journaled(h, sessionId)
3292
+ const context = waiterContext(h)
3293
+ let deliveries = 0
3294
+ const notes: string[] = []
3295
+
3296
+ for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
3297
+ const outcome = await runEscalationWaiter(context, {
3298
+ sessionId,
3299
+ envelope: { session_id: sessionId, stop_hook_active: false },
3300
+ route: {
3301
+ kind,
3302
+ deliver: async () => {
3303
+ deliveries += 1
3304
+ return { notes: [], acknowledgement: 'held' as const }
3305
+ },
3306
+ },
3307
+ processDeadlineAt: NOW + 480_000,
3308
+ })
3309
+ notes.push(...outcome.notes)
3310
+ }
3311
+
3312
+ expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
3313
+ expect(notes.join('\n')).toContain(
3314
+ `${MAX_CONTINUATION_COUNT} times without being acknowledged`,
3315
+ )
3316
+ expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
3317
+ }
3318
+ })
3319
+
3320
+ it('keeps the accepted journal when an out-of-band route rejects delivery', async () => {
3321
+ const h = harness([reply({ text: 'Hold it' })])
3322
+ writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
3323
+ writeSessionState('waiter-failed-route', h.env, { last_prompt_at: AWAY })
3324
+ registerQuestion('waiter-failed-route', h.env, { question: 'Ship it?' }, NOW)
3325
+ const context = waiterContext(h)
3326
+
3327
+ await expect(
3328
+ runEscalationWaiter(context, {
3329
+ sessionId: 'waiter-failed-route',
3330
+ envelope: { session_id: 'waiter-failed-route' },
3331
+ route: {
3332
+ kind: 'inbox-socket',
3333
+ deliver: async () => {
3334
+ throw new Error('socket unavailable')
3335
+ },
3336
+ },
3337
+ processDeadlineAt: NOW + 480_000,
3338
+ }),
3339
+ ).rejects.toThrow('socket unavailable')
3340
+
3341
+ expect(readSessionState('waiter-failed-route', h.env).accepted?.answers[0]?.reply.text).toBe(
3342
+ 'Hold it',
3343
+ )
3344
+ expect(claimQuestionPush('waiter-failed-route', h.env)).toBe(true)
3345
+ releaseQuestionPush('waiter-failed-route', h.env)
3346
+ })
3347
+ })