@raidiant/notifai 0.2.1 → 0.4.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 (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
4
5
  import type {
5
6
  CapabilityDocument,
6
7
  EvidenceSnapshot,
@@ -17,7 +18,10 @@ import {
17
18
  buildQuestions,
18
19
  assessReadiness,
19
20
  capabilitiesCommand,
21
+ configExplainCommand,
20
22
  configSetCommand,
23
+ configShowCommand,
24
+ configUnsetCommand,
21
25
  contradictingAnswer,
22
26
  describeHookFailure,
23
27
  doctorCommand,
@@ -27,6 +31,8 @@ import {
27
31
  initCommand,
28
32
  SKILLS_SOURCE,
29
33
  loginCommand,
34
+ logsCommand,
35
+ parseSince,
30
36
  projectSlugFrom,
31
37
  repliesCommand,
32
38
  sendCommand,
@@ -35,9 +41,19 @@ import {
35
41
  type CommandIo,
36
42
  type CommandSpinner,
37
43
  } from './commands.js'
38
- import { applyPlan, buildHookConfig } from './install-hooks.js'
44
+ import {
45
+ applyPlan,
46
+ buildHookConfig,
47
+ codexHookIdentityHash,
48
+ codexTrustKey,
49
+ findInstallations,
50
+ } from './install-hooks.js'
39
51
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
40
52
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
53
+ import { CONFIG_KEYS, loadConfig } from './config.js'
54
+ import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
55
+ import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
56
+ import type { Tone } from './ui/theme.js'
41
57
 
42
58
  class CapturedIo implements CommandIo {
43
59
  outLines: string[] = []
@@ -72,7 +88,7 @@ class InteractiveIo extends CapturedIo {
72
88
  intros: string[] = []
73
89
  outros: string[] = []
74
90
  spinnerEvents: string[] = []
75
- checks: { ok: boolean; message: string }[] = []
91
+ checks: { ok: boolean; message: string; tone?: Tone }[] = []
76
92
 
77
93
  override async confirm(question: string) {
78
94
  this.prompts.push(question)
@@ -111,12 +127,28 @@ class InteractiveIo extends CapturedIo {
111
127
  }
112
128
  }
113
129
 
114
- async check(ok: boolean, message: string) {
115
- this.checks.push({ ok, message })
130
+ async check(ok: boolean, message: string, tone?: Tone) {
131
+ this.checks.push({ ok, message, ...(tone === undefined ? {} : { tone }) })
116
132
  }
117
133
  }
118
134
 
135
+ function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
136
+ const installations = findInstallations(cwd, env).filter(
137
+ (installation) => installation.harness === 'codex',
138
+ )
139
+ const sections = installations.flatMap((installation) =>
140
+ installation.handlers.map((handler) => {
141
+ const key = codexTrustKey(installation, handler)
142
+ return `[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(codexHookIdentityHash(handler))}\n`
143
+ }),
144
+ )
145
+ const codexHome = env['CODEX_HOME'] as string
146
+ mkdirSync(codexHome, { recursive: true })
147
+ writeFileSync(path.join(codexHome, 'config.toml'), sections.join('\n'))
148
+ }
149
+
119
150
  function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
151
+ const testRoot = path.join(os.tmpdir(), 'notifai-cli-command-tests')
120
152
  return {
121
153
  io,
122
154
  store: {
@@ -130,7 +162,11 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
130
162
  clear: () => {},
131
163
  describe: () => 'test credential store',
132
164
  },
133
- env: { XDG_CONFIG_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests') },
165
+ env: {
166
+ XDG_CONFIG_HOME: testRoot,
167
+ XDG_STATE_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests-state'),
168
+ },
169
+ hookAdapterHome: path.join(testRoot, 'home'),
134
170
  cwd: os.tmpdir(),
135
171
  clientFactory: () => client,
136
172
  }
@@ -490,13 +526,19 @@ describe('command contracts', () => {
490
526
  return replyResponse(polls.length === 3 ? [reply] : [])
491
527
  },
492
528
  } as unknown as ApiClient
529
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-direct-reply-log-'))
493
530
  const deps = {
494
531
  ...makeDeps(io, client),
532
+ env: {
533
+ XDG_CONFIG_HOME: path.join(root, 'config'),
534
+ XDG_STATE_HOME: path.join(root, 'state'),
535
+ },
495
536
  now: () => now,
496
537
  sleep: async (milliseconds: number) => {
497
538
  now += milliseconds
498
539
  },
499
540
  }
541
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
500
542
 
501
543
  expect(
502
544
  await sendCommand(deps, {
@@ -509,6 +551,14 @@ describe('command contracts', () => {
509
551
  expect(polls).toHaveLength(3)
510
552
  expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
511
553
  expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
554
+ const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
555
+ expect(received).toHaveLength(1)
556
+ expect(received[0]?.data).toMatchObject({
557
+ request_id: receipt.request_id,
558
+ sequence: reply.seq,
559
+ device: reply.device_name,
560
+ text: reply.text,
561
+ })
512
562
  })
513
563
 
514
564
  it('backs off and retries a transient network error while waiting', async () => {
@@ -545,6 +595,97 @@ describe('command contracts', () => {
545
595
  expect(sleeps).toEqual([250])
546
596
  })
547
597
 
598
+ /**
599
+ * Regression context: Provider Acceptance and Companion Receipt succeeded,
600
+ * then a long-poll returned HTTP 500 internal_error and the blocking wait aborted
601
+ * while the reply window was still open. The answer arrived ~1 minute later
602
+ * and was recovered only on the next turn. Transient server faults must not
603
+ * end the wait; permanent client errors still may.
604
+ */
605
+ it('keeps blocking through a transient internal_error on the replies poll', async () => {
606
+ const io = new CapturedIo()
607
+ let now = 0
608
+ let replyCalls = 0
609
+ const client = {
610
+ submit: async () => receipt,
611
+ replies: async () => {
612
+ replyCalls += 1
613
+ if (replyCalls <= 2) {
614
+ throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
615
+ }
616
+ return replyResponse([reply])
617
+ },
618
+ } as unknown as ApiClient
619
+ const deps = {
620
+ ...makeDeps(io, client),
621
+ now: () => now,
622
+ sleep: async (milliseconds: number) => {
623
+ now += milliseconds
624
+ },
625
+ }
626
+
627
+ expect(
628
+ await sendCommand(deps, {
629
+ title: 'Question',
630
+ body: 'Deploy?',
631
+ reply: true,
632
+ replyTimeout: 30,
633
+ }),
634
+ ).toBe(EXIT.ok)
635
+ expect(replyCalls).toBe(3)
636
+ expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
637
+ expect(io.errLines.join('\n')).not.toContain('internal_error')
638
+ })
639
+
640
+ it('does not treat a permanent replies error as a transient wait fault', async () => {
641
+ const io = new CapturedIo()
642
+ let now = 0
643
+ let replyCalls = 0
644
+ const client = {
645
+ submit: async () => receipt,
646
+ replies: async () => {
647
+ replyCalls += 1
648
+ throw new ApiCallError(404, 'not_found', 'No such request.')
649
+ },
650
+ } as unknown as ApiClient
651
+ const deps: CommandDeps = {
652
+ ...makeDeps(io, client),
653
+ now: () => now,
654
+ sleep: async (milliseconds: number) => {
655
+ now += milliseconds
656
+ },
657
+ }
658
+ deps.env = {
659
+ ...deps.env,
660
+ XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-reply-wait-log-')),
661
+ }
662
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
663
+
664
+ expect(
665
+ await sendCommand(deps, {
666
+ title: 'Question',
667
+ body: 'Deploy?',
668
+ reply: true,
669
+ replyTimeout: 30,
670
+ }),
671
+ ).toBe(EXIT.failed)
672
+ expect(replyCalls).toBe(1)
673
+ expect(io.errLines.join('\n')).toContain('reply wait failed')
674
+ expect(io.errLines.join('\n')).toContain(receipt.request_id)
675
+ expect(io.errLines.join('\n')).toContain('not_found')
676
+ // Receipt was already printed — the durable send is not the failure.
677
+ expect(io.outLines[0]).toContain(receipt.request_id)
678
+ const waitError = readLogRecords(deps.env, { event: ['cli.error'], limit: 20 }).records.find(
679
+ (record) => record.data?.['operation'] === 'reply_wait',
680
+ )
681
+ expect(waitError?.data).toMatchObject({
682
+ kind: 'api',
683
+ request_id: receipt.request_id,
684
+ status: 404,
685
+ code: 'not_found',
686
+ })
687
+ })
688
+
548
689
  it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
549
690
  const io = new CapturedIo()
550
691
  let now = 0
@@ -615,10 +756,18 @@ describe('command contracts', () => {
615
756
  return replyResponse([reply])
616
757
  },
617
758
  } as unknown as ApiClient
759
+ const deps = makeDeps(io, client)
760
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-replies-log-'))
761
+ deps.env = {
762
+ XDG_CONFIG_HOME: path.join(root, 'config'),
763
+ XDG_STATE_HOME: path.join(root, 'state'),
764
+ }
765
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
618
766
 
619
- expect(await repliesCommand(makeDeps(io, client), receipt.request_id, { after: 7 })).toBe(EXIT.ok)
767
+ expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
620
768
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
621
769
  expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
770
+ expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
622
771
  })
623
772
 
624
773
  it('resolves replies --pending through the project session pointer and prints its id', async () => {
@@ -674,6 +823,198 @@ describe('command contracts', () => {
674
823
  expect(io.outLines[1]).toContain('yes, after the migration')
675
824
  expect(io.outLines.join('\n')).toContain('req_second')
676
825
  })
826
+
827
+ it('keeps the failing request identity when one pending reply wait throws', async () => {
828
+ const io = new CapturedIo()
829
+ const client = {
830
+ replies: async (requestId: string) => {
831
+ if (requestId === 'req_failed') {
832
+ throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
833
+ }
834
+ return replyResponse([reply])
835
+ },
836
+ } as unknown as ApiClient
837
+ const deps = makeDeps(io, client)
838
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-error-log-'))
839
+ deps.cwd = root
840
+ deps.env = {
841
+ XDG_CONFIG_HOME: path.join(root, 'config'),
842
+ XDG_STATE_HOME: path.join(root, 'state'),
843
+ }
844
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
845
+ writeSessionState('pending-error-log', deps.env, {
846
+ pending: [
847
+ { question: 'First?', request_id: 'req_healthy' },
848
+ { question: 'Second?', request_id: 'req_failed' },
849
+ { question: 'Third?', request_id: 'req_unreached' },
850
+ ],
851
+ })
852
+ writeProjectSession(root, deps.env, 'pending-error-log', Date.now(), 'codex')
853
+
854
+ expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.failed)
855
+
856
+ io.outLines = []
857
+ io.errLines = []
858
+ expect(logsCommand(deps, { request: 'req_failed', json: true, allProjects: true })).toBe(
859
+ EXIT.ok,
860
+ )
861
+ expect(io.outLines).toHaveLength(1)
862
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
863
+ event: 'cli.error',
864
+ data: { request_id: 'req_failed', operation: 'reply_wait' },
865
+ })
866
+
867
+ io.outLines = []
868
+ expect(logsCommand(deps, { request: 'req_healthy', json: true, allProjects: true })).toBe(
869
+ EXIT.ok,
870
+ )
871
+ expect(io.outLines).toHaveLength(1)
872
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
873
+ event: 'reply.received',
874
+ data: { request_id: 'req_healthy' },
875
+ })
876
+
877
+ io.outLines = []
878
+ expect(logsCommand(deps, { request: 'req_unreached', json: true, allProjects: true })).toBe(
879
+ EXIT.ok,
880
+ )
881
+ expect(io.outLines).toEqual([])
882
+ })
883
+
884
+ it('attributes a degraded multi-request wait only to the request whose polls failed', async () => {
885
+ const io = new CapturedIo()
886
+ let now = 0
887
+ let failedPolls = 0
888
+ const client = {
889
+ replies: async (requestId: string) => {
890
+ if (requestId !== 'req_degraded') return replyResponse([reply])
891
+ failedPolls += 1
892
+ if (failedPolls === 1) return replyResponse([])
893
+ throw new NetworkError('temporary disconnect')
894
+ },
895
+ } as unknown as ApiClient
896
+ const deps = makeDeps(io, client)
897
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-degraded-log-'))
898
+ deps.cwd = root
899
+ deps.env = {
900
+ XDG_CONFIG_HOME: path.join(root, 'config'),
901
+ XDG_STATE_HOME: path.join(root, 'state'),
902
+ }
903
+ deps.now = () => now
904
+ deps.sleep = async (milliseconds: number) => {
905
+ now += milliseconds
906
+ }
907
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
908
+ writeSessionState('pending-degraded-log', deps.env, {
909
+ pending: [
910
+ { question: 'First?', request_id: 'req_healthy_before' },
911
+ { question: 'Second?', request_id: 'req_degraded' },
912
+ { question: 'Third?', request_id: 'req_healthy_after' },
913
+ ],
914
+ })
915
+ writeProjectSession(root, deps.env, 'pending-degraded-log', Date.now(), 'codex')
916
+
917
+ expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.network)
918
+
919
+ io.outLines = []
920
+ io.errLines = []
921
+ expect(logsCommand(deps, { request: 'req_degraded', json: true, allProjects: true })).toBe(
922
+ EXIT.ok,
923
+ )
924
+ expect(io.outLines).toHaveLength(1)
925
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
926
+ event: 'cli.error',
927
+ data: { request_ids: ['req_degraded'], operation: 'reply_wait' },
928
+ })
929
+
930
+ for (const requestId of ['req_healthy_before', 'req_healthy_after']) {
931
+ io.outLines = []
932
+ expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
933
+ EXIT.ok,
934
+ )
935
+ expect(io.outLines).toHaveLength(1)
936
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
937
+ event: 'reply.received',
938
+ data: { request_id: requestId },
939
+ })
940
+ }
941
+ })
942
+
943
+ it('records earlier degraded requests before a later pending wait throws', async () => {
944
+ const io = new CapturedIo()
945
+ let now = 0
946
+ let degradedPolls = 0
947
+ const client = {
948
+ replies: async (requestId: string) => {
949
+ if (requestId === 'req_success') return replyResponse([reply])
950
+ if (requestId === 'req_degraded') {
951
+ degradedPolls += 1
952
+ if (degradedPolls === 1) return replyResponse([])
953
+ throw new NetworkError('temporary disconnect')
954
+ }
955
+ if (requestId === 'req_failed') {
956
+ throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
957
+ }
958
+ return replyResponse([reply])
959
+ },
960
+ } as unknown as ApiClient
961
+ const deps = makeDeps(io, client)
962
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-mixed-log-'))
963
+ deps.cwd = root
964
+ deps.env = {
965
+ XDG_CONFIG_HOME: path.join(root, 'config'),
966
+ XDG_STATE_HOME: path.join(root, 'state'),
967
+ }
968
+ deps.now = () => now
969
+ deps.sleep = async (milliseconds: number) => {
970
+ now += milliseconds
971
+ }
972
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
973
+ writeSessionState('pending-mixed-log', deps.env, {
974
+ pending: [
975
+ { question: 'Healthy?', request_id: 'req_success' },
976
+ { question: 'Degraded?', request_id: 'req_degraded' },
977
+ { question: 'Gone?', request_id: 'req_failed' },
978
+ { question: 'Unreached?', request_id: 'req_unreached' },
979
+ ],
980
+ })
981
+ writeProjectSession(root, deps.env, 'pending-mixed-log', Date.now(), 'codex')
982
+
983
+ expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.failed)
984
+
985
+ const recordsFor = (requestId: string): Array<Record<string, unknown>> => {
986
+ io.outLines = []
987
+ io.errLines = []
988
+ expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
989
+ EXIT.ok,
990
+ )
991
+ return io.outLines.map((line) => JSON.parse(line) as Record<string, unknown>)
992
+ }
993
+
994
+ expect(recordsFor('req_success')).toEqual([
995
+ expect.objectContaining({
996
+ event: 'reply.received',
997
+ data: expect.objectContaining({ request_id: 'req_success' }),
998
+ }),
999
+ ])
1000
+ expect(recordsFor('req_degraded')).toEqual([
1001
+ expect.objectContaining({
1002
+ event: 'cli.error',
1003
+ data: expect.objectContaining({
1004
+ request_ids: ['req_degraded'],
1005
+ operation: 'reply_wait',
1006
+ degraded: true,
1007
+ }),
1008
+ }),
1009
+ ])
1010
+ expect(recordsFor('req_failed')).toEqual([
1011
+ expect.objectContaining({
1012
+ event: 'cli.error',
1013
+ data: expect.objectContaining({ request_id: 'req_failed', operation: 'reply_wait' }),
1014
+ }),
1015
+ ])
1016
+ expect(recordsFor('req_unreached')).toEqual([])
1017
+ })
677
1018
  })
678
1019
 
679
1020
  describe('delivery evidence status', () => {
@@ -730,6 +1071,8 @@ describe('delivery evidence status', () => {
730
1071
  expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
731
1072
  expect(said).toContain('not a failure')
732
1073
  expect(said).toContain('attempt_started')
1074
+ expect(said).toContain('OS presentation: not observed by Notifai')
1075
+ expect(said).toContain('Reply wait:')
733
1076
  })
734
1077
 
735
1078
  it('reports the observed device receipt and measured provider-to-companion latency', async () => {
@@ -747,12 +1090,40 @@ describe('delivery evidence status', () => {
747
1090
  const said = io.outLines.join('\n')
748
1091
  expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
749
1092
  expect(said).toContain('11m 27s after Provider Acceptance')
1093
+ expect(said).toContain('OS presentation: not observed by Notifai')
1094
+ expect(said).toContain('Reply received: not yet recorded')
1095
+ expect(said).toContain('Reply wait: no answer stored yet')
1096
+ })
1097
+
1098
+ it('separates a stored reply from Delivery so a wait fault is not misread as non-delivery', async () => {
1099
+ const io = new CapturedIo()
1100
+ const base = snapshot({
1101
+ state: 'observed',
1102
+ observed_at: '2026-08-05T13:17:17.000Z',
1103
+ latency_ms: 1_000,
1104
+ })
1105
+ base.deliveries[0]!.events.push({
1106
+ stage: 'reply_received',
1107
+ source: 'companion',
1108
+ reason: null,
1109
+ attempt: null,
1110
+ occurred_at: '2026-08-05T13:18:00.000Z',
1111
+ })
1112
+ const client = { evidence: async () => base } as unknown as ApiClient
1113
+
1114
+ expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
1115
+ const said = io.outLines.join('\n')
1116
+ expect(said).toContain('Provider Acceptance: accepted')
1117
+ expect(said).toContain('Companion Receipt')
1118
+ expect(said).toContain('OS presentation: not observed by Notifai')
1119
+ expect(said).toContain('Reply received: yes')
1120
+ expect(said).toContain('answers are on the server')
750
1121
  })
751
1122
  })
752
1123
 
753
1124
  describe('Cursor hook commands', () => {
754
- const execPath = '/usr/local/bin/node'
755
- const scriptPath = '/opt/notifai/dist/main.js'
1125
+ const execPath = process.execPath
1126
+ const scriptPath = fileURLToPath(import.meta.url)
756
1127
 
757
1128
  it('installs native Cursor hooks with bounded chained answer continuations', () => {
758
1129
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
@@ -846,8 +1217,8 @@ describe('Cursor hook commands', () => {
846
1217
  })
847
1218
 
848
1219
  describe('harness activation guidance', () => {
849
- const execPath = '/usr/local/bin/node'
850
- const scriptPath = '/opt/notifai/dist/main.js'
1220
+ const execPath = process.execPath
1221
+ const scriptPath = fileURLToPath(import.meta.url)
851
1222
 
852
1223
  it('does not require a Claude Code restart for project hook files', () => {
853
1224
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
@@ -877,7 +1248,7 @@ describe('harness activation guidance', () => {
877
1248
  expect(output).not.toMatch(/trust|approve/i)
878
1249
  })
879
1250
 
880
- it('keeps OpenCode permission prompts local and reports its continuation limit', async () => {
1251
+ it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
881
1252
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
882
1253
  const io = new CapturedIo()
883
1254
  const client = {
@@ -885,21 +1256,33 @@ describe('harness activation guidance', () => {
885
1256
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
886
1257
  listDevices: async () => ({ devices: [] }),
887
1258
  } as unknown as ApiClient
888
- const deps = { ...makeDeps(io, client), cwd }
1259
+ const base = makeDeps(io, client)
1260
+ const deps = {
1261
+ ...base,
1262
+ cwd,
1263
+ env: {
1264
+ ...base.env,
1265
+ HOME: path.join(cwd, 'home'),
1266
+ CODEX_HOME: path.join(cwd, 'codex-home'),
1267
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
1268
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
1269
+ },
1270
+ }
889
1271
 
890
1272
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
891
1273
  EXIT.ok,
892
1274
  )
893
1275
 
894
1276
  expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
895
- expect(io.outLines.join('\n')).toContain('next user prompt')
1277
+ expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
896
1278
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
897
1279
  const plugin = readFileSync(pluginFile, 'utf8')
898
1280
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
899
1281
 
900
1282
  io.outLines = []
901
1283
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
902
- expect(io.outLines.join('\n')).toContain('hooks (opencode continuation)')
1284
+ expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
1285
+ expect(io.outLines.join('\n')).toContain('no proven answer continuation')
903
1286
  expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
904
1287
 
905
1288
  writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
@@ -908,6 +1291,107 @@ describe('harness activation guidance', () => {
908
1291
  expect(io.outLines.join('\n')).toContain('hooks (adapter)')
909
1292
  expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
910
1293
  })
1294
+
1295
+ it('refuses a symlinked OpenCode plugin without touching its target', () => {
1296
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-symlink-'))
1297
+ const plugin = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
1298
+ const target = path.join(cwd, 'foreign.js')
1299
+ mkdirSync(path.dirname(plugin), { recursive: true })
1300
+ writeFileSync(target, '// notifai managed opencode plugin\nleave me\n')
1301
+ symlinkSync(target, plugin)
1302
+ const io = new CapturedIo()
1303
+ const deps = {
1304
+ ...makeDeps(io, {} as ApiClient),
1305
+ cwd,
1306
+ env: { XDG_STATE_HOME: path.join(cwd, 'state') },
1307
+ }
1308
+
1309
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
1310
+ EXIT.failed,
1311
+ )
1312
+ expect(io.errLines.join('\n')).toMatch(/not a regular file/)
1313
+ expect(readFileSync(target, 'utf8')).toBe('// notifai managed opencode plugin\nleave me\n')
1314
+ // Adapter preparation commits first. A definition failure leaves a valid
1315
+ // shared adapter (useful to any existing harness) and never half-writes the
1316
+ // rejected definition.
1317
+ expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
1318
+ expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
1319
+ })
1320
+ })
1321
+
1322
+ describe('stable hook installation', () => {
1323
+ it('keeps definition bytes stable across config, CLI target, and project/global changes', async () => {
1324
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-stable-hook-install-'))
1325
+ const firstCli = path.join(cwd, 'first cli.js')
1326
+ const secondCli = path.join(cwd, 'second cli.js')
1327
+ writeFileSync(firstCli, '')
1328
+ writeFileSync(secondCli, '')
1329
+ const io = new CapturedIo()
1330
+ const env = {
1331
+ HOME: path.join(cwd, 'home'),
1332
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
1333
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1334
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
1335
+ CODEX_HOME: path.join(cwd, 'codex-home'),
1336
+ }
1337
+ const client = {
1338
+ health: async () => true,
1339
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1340
+ listDevices: async () => ({ devices: [] }),
1341
+ } as unknown as ApiClient
1342
+ const deps = { ...makeDeps(io, client), cwd, env }
1343
+ const local = path.join(cwd, '.claude', 'settings.local.json')
1344
+
1345
+ expect(
1346
+ hooksInstallCommand(deps, {
1347
+ harness: 'claude-code',
1348
+ execPath: process.execPath,
1349
+ scriptPath: firstCli,
1350
+ }),
1351
+ ).toBe(EXIT.ok)
1352
+ const firstDefinition = readFileSync(local, 'utf8')
1353
+ const firstAdapter = readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')
1354
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
1355
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
1356
+ env.XDG_CONFIG_HOME = path.join(cwd, 'different-config')
1357
+ env.XDG_STATE_HOME = path.join(cwd, 'different-state')
1358
+
1359
+ expect(
1360
+ hooksInstallCommand(deps, {
1361
+ harness: 'claude-code',
1362
+ execPath: process.execPath,
1363
+ scriptPath: secondCli,
1364
+ }),
1365
+ ).toBe(EXIT.ok)
1366
+ expect(readFileSync(local, 'utf8')).toBe(firstDefinition)
1367
+ expect(readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')).not.toBe(firstAdapter)
1368
+ expect(firstDefinition).toContain(hookAdapterPath(deps.hookAdapterHome))
1369
+ expect(firstDefinition).not.toContain(firstCli)
1370
+ expect(firstDefinition).not.toContain(secondCli)
1371
+
1372
+ expect(
1373
+ hooksInstallCommand(deps, {
1374
+ harness: 'claude-code',
1375
+ global: true,
1376
+ execPath: process.execPath,
1377
+ scriptPath: secondCli,
1378
+ }),
1379
+ ).toBe(EXIT.ok)
1380
+ const global = readFileSync(path.join(env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')
1381
+ expect(global).toBe(firstDefinition)
1382
+
1383
+ io.outLines = []
1384
+ await doctorCommand(deps, {})
1385
+ expect(io.outLines.join('\n')).toMatch(/hooks \(duplicates\).*project or global routing/is)
1386
+
1387
+ expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
1388
+ expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
1389
+ expect(
1390
+ findInstallations(cwd, env, deps.hookAdapterHome).filter(
1391
+ (item) => item.harness === 'claude-code',
1392
+ ),
1393
+ ).toHaveLength(1)
1394
+ })
911
1395
  })
912
1396
 
913
1397
  describe('projectSlugFrom', () => {
@@ -919,6 +1403,261 @@ describe('projectSlugFrom', () => {
919
1403
  })
920
1404
  })
921
1405
 
1406
+ describe('logs', () => {
1407
+ function logDeps(io: CapturedIo): CommandDeps {
1408
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-logs-cmd-'))
1409
+ return {
1410
+ ...makeDeps(io, {} as ApiClient),
1411
+ cwd,
1412
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg'), XDG_STATE_HOME: path.join(cwd, 'state') },
1413
+ }
1414
+ }
1415
+
1416
+ function seed(deps: CommandDeps, project?: string): void {
1417
+ const logger = createLogger({ env: deps.env, runId: 'r_seed', cmd: 'send' })
1418
+ if (project !== undefined) logger.bind({ project })
1419
+ logger.info('send.submitted', { request_id: 'req_seeded' })
1420
+ logger.error('cli.error', { message: 'the server said no' })
1421
+ }
1422
+
1423
+ it('shows the recent records, newest last', () => {
1424
+ const io = new CapturedIo()
1425
+ const deps = logDeps(io)
1426
+ seed(deps)
1427
+ expect(logsCommand(deps, {})).toBe(EXIT.ok)
1428
+ expect(io.outLines).toHaveLength(2)
1429
+ expect(io.outLines[0]).toContain('send.submitted')
1430
+ expect(io.outLines[1]).toContain('cli.error')
1431
+ })
1432
+
1433
+ it('keeps stdout pure JSONL under --json so a parser needs no filtering', () => {
1434
+ // Every word of explanation goes to stderr. A machine reading stdout gets
1435
+ // records and nothing else.
1436
+ const io = new CapturedIo()
1437
+ const deps = logDeps(io)
1438
+ seed(deps)
1439
+ expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
1440
+ for (const line of io.outLines) {
1441
+ expect(() => JSON.parse(line) as unknown).not.toThrow()
1442
+ }
1443
+ expect(io.outLines).toHaveLength(2)
1444
+ })
1445
+
1446
+ it('narrows to the failures', () => {
1447
+ const io = new CapturedIo()
1448
+ const deps = logDeps(io)
1449
+ seed(deps)
1450
+ expect(logsCommand(deps, { level: 'error', json: true })).toBe(EXIT.ok)
1451
+ expect(io.outLines).toHaveLength(1)
1452
+ expect(io.outLines[0]).toContain('cli.error')
1453
+ })
1454
+
1455
+ it('narrows to one notification request', () => {
1456
+ const io = new CapturedIo()
1457
+ const deps = logDeps(io)
1458
+ seed(deps)
1459
+ expect(logsCommand(deps, { request: 'req_seeded', json: true })).toBe(EXIT.ok)
1460
+ expect(io.outLines).toHaveLength(1)
1461
+ })
1462
+
1463
+ it('scopes to this project unless told otherwise', () => {
1464
+ const io = new CapturedIo()
1465
+ const deps = logDeps(io)
1466
+ mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
1467
+ writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'project = "mine"\n')
1468
+ seed(deps, 'mine')
1469
+ seed(deps, 'someone-elses')
1470
+
1471
+ // A machine commonly runs several agents in several worktrees at once, and
1472
+ // an answer interleaved with three other projects answers nothing.
1473
+ expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
1474
+ expect(io.outLines).toHaveLength(2)
1475
+ io.outLines.length = 0
1476
+ expect(logsCommand(deps, { json: true, allProjects: true })).toBe(EXIT.ok)
1477
+ expect(io.outLines).toHaveLength(4)
1478
+ })
1479
+
1480
+ it('rejects an unreadable time, unknown event, and invalid limits', () => {
1481
+ const io = new CapturedIo()
1482
+ const deps = logDeps(io)
1483
+ expect(logsCommand(deps, { since: 'yesterday-ish' })).toBe(EXIT.usage)
1484
+ expect(io.errLines.join('\n')).toContain('10m')
1485
+ io.errLines.length = 0
1486
+ expect(logsCommand(deps, { event: ['send.exploded'] })).toBe(EXIT.usage)
1487
+ expect(io.errLines.join('\n')).toContain('send.submitted')
1488
+ io.errLines.length = 0
1489
+ expect(logsCommand(deps, { limit: Number.NaN })).toBe(EXIT.usage)
1490
+ expect(io.errLines.join('\n')).toContain('--limit')
1491
+ io.errLines.length = 0
1492
+ expect(logsCommand(deps, { limit: 2_001 })).toBe(EXIT.usage)
1493
+ expect(io.errLines.join('\n')).toContain('cannot exceed 2000')
1494
+ })
1495
+
1496
+ it.each([
1497
+ [{ path: true, clear: true }, '--path'],
1498
+ [{ path: true, request: 'req_seeded' }, '--path'],
1499
+ [{ clear: true, since: '1d' }, '--clear'],
1500
+ [{ project: 'mine', allProjects: true }, '--project'],
1501
+ [{ limit: 10, all: true }, '--limit'],
1502
+ ] as const)('rejects incompatible log options %j', (flags, namedFlag) => {
1503
+ const io = new CapturedIo()
1504
+ expect(logsCommand(logDeps(io), flags)).toBe(EXIT.usage)
1505
+ expect(io.errLines.join('\n')).toContain(namedFlag)
1506
+ })
1507
+
1508
+ it('accepts relative and absolute times', () => {
1509
+ const now = Date.parse('2026-08-11T12:00:00Z')
1510
+ expect(parseSince('10m', now)).toBe(now - 600_000)
1511
+ expect(parseSince('2h', now)).toBe(now - 7_200_000)
1512
+ expect(parseSince('1d', now)).toBe(now - 86_400_000)
1513
+ expect(parseSince('2026-08-11T11:00:00Z', now)).toBe(Date.parse('2026-08-11T11:00:00Z'))
1514
+ expect(parseSince('whenever', now)).toBeNull()
1515
+ })
1516
+
1517
+ it('says why it is empty, and what to do about it', () => {
1518
+ // "No records" with no explanation sends the reader looking for a bug that
1519
+ // is actually a setting.
1520
+ const io = new CapturedIo()
1521
+ const deps = logDeps(io)
1522
+ mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
1523
+ writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'log_level = "off"\n')
1524
+ expect(logsCommand(deps, {})).toBe(EXIT.ok)
1525
+ expect(io.errLines.join('\n')).toContain('log_level is off')
1526
+ expect(io.errLines.join('\n')).toContain('config set log_level info')
1527
+ })
1528
+
1529
+ it('points at the files, and says they stay here', () => {
1530
+ const io = new CapturedIo()
1531
+ const deps = logDeps(io)
1532
+ seed(deps)
1533
+ expect(logsCommand(deps, { path: true })).toBe(EXIT.ok)
1534
+ expect(io.outLines[0]).toContain('notifai.jsonl')
1535
+ expect(io.errLines.join('\n')).toContain('stay on this machine')
1536
+ })
1537
+
1538
+ it('reports local_only in the machine-readable form', () => {
1539
+ const io = new CapturedIo()
1540
+ const deps = logDeps(io)
1541
+ expect(logsCommand(deps, { path: true, json: true })).toBe(EXIT.ok)
1542
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
1543
+ expect(parsed['local_only']).toBe(true)
1544
+ expect(parsed['level']).toBe('info')
1545
+ })
1546
+
1547
+ it('clears the files when asked', () => {
1548
+ const io = new CapturedIo()
1549
+ const deps = logDeps(io)
1550
+ seed(deps)
1551
+ expect(logsCommand(deps, { clear: true })).toBe(EXIT.ok)
1552
+ expect(logsDiskUsage(deps.env).files).toBe(0)
1553
+ })
1554
+ })
1555
+
1556
+ describe('config surfaces', () => {
1557
+ function configDeps(io: CapturedIo): CommandDeps {
1558
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
1559
+ return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
1560
+ }
1561
+
1562
+ it('gives an agent the same flat key = value output it always had', () => {
1563
+ // The whole point of gating the readable rendering on a human terminal.
1564
+ // Anything parsing this today must keep parsing it tomorrow.
1565
+ const io = new CapturedIo()
1566
+ expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1567
+ expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
1568
+ expect(io.outLines[0]).toMatch(/^base_url = "/)
1569
+ for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
1570
+ })
1571
+
1572
+ it('explains each setting once a human is at the terminal', () => {
1573
+ const io = new InteractiveIo()
1574
+ expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1575
+ const text = io.outLines.join('\n')
1576
+ expect(text).toContain('Questions & presence')
1577
+ expect(text).toContain('Whether sitting at this keyboard holds a question back')
1578
+ })
1579
+
1580
+ it('keeps --plain available to a human who wants the parseable form', () => {
1581
+ const io = new InteractiveIo()
1582
+ expect(configShowCommand(configDeps(io), { plain: true })).toBe(EXIT.ok)
1583
+ expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
1584
+ })
1585
+
1586
+ it('explains one setting, and says so in JSON when asked', () => {
1587
+ const io = new InteractiveIo()
1588
+ expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
1589
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
1590
+ expect(parsed['key']).toBe('require_idle')
1591
+ expect(parsed['accepts']).toBe('true or false')
1592
+ expect(parsed['detail']).toContain('Off (the default)')
1593
+ expect(parsed['detail']).toContain('Turn this on')
1594
+ })
1595
+
1596
+ it('rejects an unknown setting and points at the nearest real one', () => {
1597
+ const io = new CapturedIo()
1598
+ expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
1599
+ expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
1600
+ })
1601
+
1602
+ it('refuses an enum value the sender would later reject', async () => {
1603
+ // `config set sound whatever` used to be written straight to disk: the
1604
+ // typo only surfaced when a notification failed to carry the sound.
1605
+ const io = new CapturedIo()
1606
+ expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
1607
+ EXIT.usage,
1608
+ )
1609
+ expect(io.errLines[0]).toContain('default, done, attention, alert, none')
1610
+ })
1611
+
1612
+ it('still accepts a legal enum value', async () => {
1613
+ const io = new CapturedIo()
1614
+ expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
1615
+ })
1616
+
1617
+ it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
1618
+ const io = new CapturedIo()
1619
+ const deps = configDeps(io)
1620
+ const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
1621
+ mkdirSync(path.dirname(configFile), { recursive: true })
1622
+ writeFileSync(configFile, 'wait_seconds = 20\nsound = "done"\n')
1623
+
1624
+ expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
1625
+
1626
+ const remaining = readFileSync(configFile, 'utf8')
1627
+ expect(remaining).toContain('sound = "done"')
1628
+ expect(remaining).not.toContain('wait_seconds')
1629
+ const resolved = loadConfig({ cwd: deps.cwd, env: deps.env })
1630
+ expect(resolved.wait_seconds).toEqual({ value: 10, source: 'default' })
1631
+ expect(resolved.sound.source).toMatch(/^global:/)
1632
+ })
1633
+
1634
+ it('refuses to create a redundant machine override equal to the shipped default', async () => {
1635
+ const io = new CapturedIo()
1636
+ const deps = configDeps(io)
1637
+
1638
+ expect(await configSetCommand(deps, 'wait_seconds', '10', { yes: true })).toBe(EXIT.usage)
1639
+ expect(io.errLines.join('\n')).toContain('already the shipped default')
1640
+ expect(io.errLines.join('\n')).toContain('notifai config unset wait_seconds')
1641
+ expect(existsSync(path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml'))).toBe(false)
1642
+ })
1643
+
1644
+ it('allows a project to choose the shipped value when it masks a machine override', async () => {
1645
+ const io = new CapturedIo()
1646
+ const deps = configDeps(io)
1647
+ const globalFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
1648
+ mkdirSync(path.dirname(globalFile), { recursive: true })
1649
+ writeFileSync(globalFile, 'wait_seconds = 20\n')
1650
+
1651
+ expect(await configSetCommand(deps, 'wait_seconds', '10', { project: true, yes: true })).toBe(
1652
+ EXIT.ok,
1653
+ )
1654
+ expect(loadConfig({ cwd: deps.cwd, env: deps.env }).wait_seconds).toMatchObject({
1655
+ value: 10,
1656
+ source: expect.stringMatching(/^project:/),
1657
+ })
1658
+ })
1659
+ })
1660
+
922
1661
  describe('interactive command UX', () => {
923
1662
  it('styles login pairing progress for a human terminal', async () => {
924
1663
  const io = new InteractiveIo()
@@ -1034,6 +1773,25 @@ describe('interactive command UX', () => {
1034
1773
  )
1035
1774
  })
1036
1775
 
1776
+ it('asks the same layer question when returning a setting to its inherited value', async () => {
1777
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
1778
+ const io = new InteractiveIo()
1779
+ io.selectAnswer = 'local'
1780
+ const localFile = path.join(cwd, '.notifai', 'config.local.toml')
1781
+ mkdirSync(path.dirname(localFile), { recursive: true })
1782
+ writeFileSync(localFile, 'sound = "done"\n')
1783
+ const deps = {
1784
+ ...makeDeps(io, {} as ApiClient),
1785
+ cwd,
1786
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
1787
+ }
1788
+
1789
+ expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
1790
+ expect(io.prompts[0]).toBe('Where should this setting live?')
1791
+ expect(io.prompts[1]).toContain(localFile)
1792
+ expect(existsSync(localFile)).toBe(false)
1793
+ })
1794
+
1037
1795
  it('rejects numeric config values that resolution would otherwise silently clamp', async () => {
1038
1796
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-bounds-'))
1039
1797
  const io = new CapturedIo()
@@ -1051,8 +1809,10 @@ describe('interactive command UX', () => {
1051
1809
  EXIT.usage,
1052
1810
  )
1053
1811
  expect(io.errLines).toEqual([
1054
- 'ask_grace_seconds must be between 0 and 540.',
1055
- '"1.5" is not an integer.',
1812
+ 'ask_grace_seconds must be between 0 and 334.',
1813
+ // Names the key and its range: `"1.5" is not an integer` left the reader
1814
+ // to work out which of the two settings they had just mistyped.
1815
+ 'ask_grace_seconds takes a whole number from 0s–334s, not "1.5".',
1056
1816
  ])
1057
1817
  expect(existsSync(configFile)).toBe(false)
1058
1818
  })
@@ -1081,6 +1841,18 @@ describe('interactive command UX', () => {
1081
1841
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
1082
1842
  expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
1083
1843
  expect(io.outLines).toEqual([])
1844
+
1845
+ // Four tones, not two. A boolean has to round `optional-gap` and `unknown`
1846
+ // to pass or fail, and rounding them to pass put a tick beside things the
1847
+ // user had declined and beside things nothing had checked.
1848
+ const tone = (prefix: string): Tone | undefined =>
1849
+ io.checks.find((check) => check.message.startsWith(prefix))?.tone
1850
+ expect(tone('This machine:')).toBe('bad')
1851
+ expect(tone('Protocol version:')).toBe('ok')
1852
+ // Never evaluated: the account cannot be checked without a credential.
1853
+ expect(tone('Account:')).toBe('pending')
1854
+ // Legitimately declined rather than broken.
1855
+ expect(tone('Project identity:')).toBe('warn')
1084
1856
  })
1085
1857
 
1086
1858
  it('keeps doctor JSON as one machine-readable stdout document', async () => {
@@ -1181,7 +1953,7 @@ describe('init', () => {
1181
1953
  source: 'Raidiant-io/notifai',
1182
1954
  sourceType: 'github',
1183
1955
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1184
- ref: 'v0.2.1',
1956
+ ref: 'v0.4.0',
1185
1957
  }
1186
1958
  }
1187
1959
 
@@ -1284,7 +2056,7 @@ describe('init', () => {
1284
2056
  })
1285
2057
 
1286
2058
  it('pins the skill installer to the tagged public release syntax', () => {
1287
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.2.1')
2059
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.4.0')
1288
2060
  expect(SKILLS_SOURCE).not.toContain('@v')
1289
2061
  })
1290
2062
 
@@ -1307,7 +2079,7 @@ describe('init', () => {
1307
2079
  source: 'Raidiant-io/notifai',
1308
2080
  sourceType: 'github',
1309
2081
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1310
- ref: 'v0.2.1',
2082
+ ref: 'v0.4.0',
1311
2083
  },
1312
2084
  ],
1313
2085
  }),
@@ -2035,7 +2807,9 @@ describe('an outage is not an answer', () => {
2035
2807
  * agent scripted to read exit 3 as "nobody objected" would proceed against a
2036
2808
  * refusal it never saw.
2037
2809
  */
2038
- function outageAfterFirstPoll(io: CapturedIo): CommandDeps {
2810
+ function outageAfterFirstPoll(io: CapturedIo, fault: () => never = () => {
2811
+ throw new NetworkError('link went down')
2812
+ }): CommandDeps {
2039
2813
  let now = 0
2040
2814
  let polls = 0
2041
2815
  const client = {
@@ -2043,7 +2817,7 @@ describe('an outage is not an answer', () => {
2043
2817
  replies: async () => {
2044
2818
  polls += 1
2045
2819
  if (polls === 1) return replyResponse([])
2046
- throw new NetworkError('link went down')
2820
+ fault()
2047
2821
  },
2048
2822
  } as unknown as ApiClient
2049
2823
  return {
@@ -2069,6 +2843,27 @@ describe('an outage is not an answer', () => {
2069
2843
  expect(exit).not.toBe(EXIT.noReply)
2070
2844
  expect(exit).toBe(EXIT.network)
2071
2845
  expect(io.errLines.join('\n')).toContain('could not find out')
2846
+ expect(io.errLines.join('\n')).toContain(receipt.request_id)
2847
+ })
2848
+
2849
+ it('treats a late internal_error outage the same as a network outage', async () => {
2850
+ const io = new CapturedIo()
2851
+ const exit = await sendCommand(
2852
+ outageAfterFirstPoll(io, () => {
2853
+ throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
2854
+ }),
2855
+ {
2856
+ title: 'Question',
2857
+ body: 'Deploy to production?',
2858
+ reply: true,
2859
+ replyTimeout: 10,
2860
+ },
2861
+ )
2862
+
2863
+ expect(exit).not.toBe(EXIT.noReply)
2864
+ expect(exit).toBe(EXIT.network)
2865
+ expect(io.errLines.join('\n')).toContain('could not find out')
2866
+ expect(io.errLines.join('\n')).toContain('Provider Acceptance')
2072
2867
  })
2073
2868
 
2074
2869
  it('marks the JSON so an agent reading it programmatically can tell', async () => {
@@ -2107,8 +2902,8 @@ describe('an outage is not an answer', () => {
2107
2902
  })
2108
2903
 
2109
2904
  describe('asking before the hooks have ever run', () => {
2110
- const execPath = '/usr/local/bin/node'
2111
- const scriptPath = '/opt/notifai/dist/main.js'
2905
+ const execPath = process.execPath
2906
+ const scriptPath = fileURLToPath(import.meta.url)
2112
2907
 
2113
2908
  it('names the active Codex harness instead of unrelated installed adapters', () => {
2114
2909
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
@@ -2151,7 +2946,7 @@ describe('asking before the hooks have ever run', () => {
2151
2946
  io.outLines = []
2152
2947
 
2153
2948
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2154
- expect(io.errLines.join(' ')).toMatch(/belongs to another Codex session/i)
2949
+ expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
2155
2950
  expect(io.outLines).not.toContain(
2156
2951
  'Question registered. Ask it in the conversation as usual and end your turn.',
2157
2952
  )
@@ -2169,8 +2964,11 @@ describe('asking before the hooks have ever run', () => {
2169
2964
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2170
2965
 
2171
2966
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2172
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42 })
2967
+ trustInstalledCodexHooks(cwd, env)
2968
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
2173
2969
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
2970
+ writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
2971
+ writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
2174
2972
  io.outLines = []
2175
2973
 
2176
2974
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
@@ -2179,6 +2977,40 @@ describe('asking before the hooks have ever run', () => {
2179
2977
  )
2180
2978
  })
2181
2979
 
2980
+ it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
2981
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-stale-trust-'))
2982
+ const io = new CapturedIo()
2983
+ const env = {
2984
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2985
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2986
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2987
+ CODEX_THREAD_ID: 'codex-current-thread',
2988
+ }
2989
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2990
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2991
+ trustInstalledCodexHooks(cwd, env)
2992
+ const stop = findInstallations(cwd, env)
2993
+ .find((installation) => installation.harness === 'codex')
2994
+ ?.handlers.find((handler) => handler.event === 'Stop')
2995
+ expect(stop).toBeDefined()
2996
+ const configFile = path.join(env.CODEX_HOME, 'config.toml')
2997
+ writeFileSync(
2998
+ configFile,
2999
+ readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
3000
+ )
3001
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3002
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3003
+ io.outLines = []
3004
+
3005
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3006
+ expect(io.errLines.join('\n')).toMatch(/Stop.*changed since it was trusted.*\/hooks/is)
3007
+
3008
+ io.outLines = []
3009
+ await doctorCommand(deps, {})
3010
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
3011
+ expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
3012
+ })
3013
+
2182
3014
  it('gives doctor the same active-Codex diagnosis as ask', async () => {
2183
3015
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
2184
3016
  const io = new CapturedIo()
@@ -2207,7 +3039,7 @@ describe('asking before the hooks have ever run', () => {
2207
3039
  source: 'Raidiant-io/notifai',
2208
3040
  sourceType: 'github',
2209
3041
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2210
- ref: 'v0.2.1',
3042
+ ref: 'v0.4.0',
2211
3043
  },
2212
3044
  ]
2213
3045
  : [],
@@ -2244,7 +3076,7 @@ describe('asking before the hooks have ever run', () => {
2244
3076
  io.outLines = []
2245
3077
 
2246
3078
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2247
- expect(io.errLines.join(' ')).toMatch(/another Claude Code session/i)
3079
+ expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
2248
3080
  })
2249
3081
 
2250
3082
  it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
@@ -2262,7 +3094,7 @@ describe('asking before the hooks have ever run', () => {
2262
3094
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2263
3095
  EXIT.ok,
2264
3096
  )
2265
- writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
3097
+ writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
2266
3098
  writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
2267
3099
  io.outLines = []
2268
3100
 
@@ -2270,6 +3102,80 @@ describe('asking before the hooks have ever run', () => {
2270
3102
  expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
2271
3103
  })
2272
3104
 
3105
+ it('fails closed when Claude Code does not expose an exact session id', () => {
3106
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-no-id-'))
3107
+ const io = new CapturedIo()
3108
+ const env = {
3109
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3110
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3111
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
3112
+ CLAUDECODE: '1',
3113
+ }
3114
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3115
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3116
+ EXIT.ok,
3117
+ )
3118
+ writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
3119
+ writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
3120
+ io.outLines = []
3121
+
3122
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3123
+ expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
3124
+ expect(readSessionState('claude-last-writer', env).pending).toBeUndefined()
3125
+ })
3126
+
3127
+ it('rejects duplicate active Codex definitions before registration', () => {
3128
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-duplicate-'))
3129
+ const io = new CapturedIo()
3130
+ const env = {
3131
+ HOME: path.join(cwd, 'home'),
3132
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3133
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3134
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3135
+ CODEX_THREAD_ID: 'codex-current-thread',
3136
+ }
3137
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3138
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3139
+ expect(
3140
+ hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
3141
+ ).toBe(EXIT.ok)
3142
+ trustInstalledCodexHooks(cwd, env)
3143
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3144
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3145
+ io.outLines = []
3146
+
3147
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3148
+ expect(io.errLines.join('\n')).toMatch(/2 Codex definitions are active/i)
3149
+ expect(readSessionState('codex-current-thread', env).pending).toBeUndefined()
3150
+ })
3151
+
3152
+ it('does not let UserPromptSubmit alone count as a working turn-end route', async () => {
3153
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-prompt-only-'))
3154
+ const io = new CapturedIo()
3155
+ const env = {
3156
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3157
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3158
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
3159
+ CLAUDECODE: '1',
3160
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
3161
+ }
3162
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3163
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3164
+ EXIT.ok,
3165
+ )
3166
+ writeSessionState('claude-current', env, { last_prompt_at: 42 })
3167
+ writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
3168
+ io.outLines = []
3169
+
3170
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3171
+ expect(io.errLines.join('\n')).toMatch(/Stop hook has not been observed/)
3172
+
3173
+ const readiness = await assessReadiness(deps)
3174
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3175
+ expect(fired?.status).toBe('gap')
3176
+ expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
3177
+ })
3178
+
2273
3179
  it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
2274
3180
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
2275
3181
  const io = new CapturedIo()
@@ -2287,10 +3193,31 @@ describe('asking before the hooks have ever run', () => {
2287
3193
  io.outLines = []
2288
3194
 
2289
3195
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2290
- expect(io.errLines.join(' ')).toMatch(/another OpenCode session/i)
3196
+ expect(io.errLines.join(' ')).toMatch(/active OpenCode session:.*has not published/i)
3197
+ })
3198
+
3199
+ it('rejects OpenCode before registration even with an exact matching pointer', () => {
3200
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-unsupported-'))
3201
+ const io = new CapturedIo()
3202
+ const env = {
3203
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3204
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3205
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
3206
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
3207
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
3208
+ }
3209
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3210
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3211
+ writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
3212
+ writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
3213
+ io.outLines = []
3214
+
3215
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3216
+ expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
3217
+ expect(readSessionState('opencode-current', env).pending).toBeUndefined()
2291
3218
  })
2292
3219
 
2293
- it('recognizes Cursor only from its active-agent marker', () => {
3220
+ it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
2294
3221
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
2295
3222
  const io = new CapturedIo()
2296
3223
  const env = {
@@ -2300,12 +3227,13 @@ describe('asking before the hooks have ever run', () => {
2300
3227
  }
2301
3228
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2302
3229
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2303
- writeSessionState('cursor-live', env, { last_prompt_at: 42 })
3230
+ writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
2304
3231
  writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
2305
3232
  io.outLines = []
2306
3233
 
2307
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2308
- expect(readSessionState('cursor-live', env).pending?.[0]?.question).toBe('Ship it?')
3234
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3235
+ expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
3236
+ expect(readSessionState('cursor-live', env).pending).toBeUndefined()
2309
3237
  })
2310
3238
 
2311
3239
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
@@ -2383,7 +3311,7 @@ describe('asking before the hooks have ever run', () => {
2383
3311
  expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
2384
3312
  })
2385
3313
 
2386
- it('fails doctor when runtime waits outgrow an installed Stop timeout', async () => {
3314
+ it('keeps an installed Stop timeout valid when runtime timing preferences change', async () => {
2387
3315
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
2388
3316
  const io = new CapturedIo()
2389
3317
  const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
@@ -2395,9 +3323,11 @@ describe('asking before the hooks have ever run', () => {
2395
3323
  writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
2396
3324
  io.outLines = []
2397
3325
 
2398
- expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2399
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(timeout\)/)
2400
- expect(io.outLines.join('\n')).toContain('notifai hooks install --harness claude-code')
3326
+ await doctorCommand(deps, {})
3327
+ expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(timeout\)/)
3328
+ expect(io.outLines.join('\n')).toContain(
3329
+ 'declared Stop timeouts match the fixed 540s process budget where required; Codex uses host defaults',
3330
+ )
2401
3331
  })
2402
3332
 
2403
3333
  it('documents the failing exit contract in doctor JSON', async () => {
@@ -2415,10 +3345,7 @@ describe('asking before the hooks have ever run', () => {
2415
3345
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
2416
3346
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
2417
3347
  hooks: buildHookConfig({
2418
- execPath: '/usr/bin/node',
2419
- scriptPath: '/opt/notifai/main.js',
2420
- replyTimeoutSeconds: 180,
2421
- graceSeconds: 300,
3348
+ adapterPath: path.join(cwd, 'notifai', 'hook-adapter'),
2422
3349
  }),
2423
3350
  })
2424
3351
 
@@ -2447,7 +3374,7 @@ describe('asking before the hooks have ever run', () => {
2447
3374
  expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
2448
3375
  })
2449
3376
 
2450
- it('keeps OpenCode activation separate from answer continuation', () => {
3377
+ it('keeps OpenCode activation separate from unsupported answer continuation', () => {
2451
3378
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
2452
3379
  const io = new CapturedIo()
2453
3380
  const deps = {
@@ -2465,7 +3392,7 @@ describe('asking before the hooks have ever run', () => {
2465
3392
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2466
3393
  const said = io.errLines.join(' ')
2467
3394
  expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
2468
- expect(said).toContain('device answer is delivered on the next prompt')
3395
+ expect(said).toContain('non-blocking question continuation is intentionally unsupported')
2469
3396
  })
2470
3397
 
2471
3398
  it('says to install when nothing is installed at all', () => {