@raidiant/notifai 0.3.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.
Files changed (87) hide show
  1. package/README.md +19 -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 +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  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 +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  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 +97 -36
  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/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/claude-wake.test.ts +320 -0
  52. package/src/claude-wake.ts +489 -0
  53. package/src/client.test.ts +12 -0
  54. package/src/client.ts +72 -4
  55. package/src/codex-wake.test.ts +253 -0
  56. package/src/codex-wake.ts +288 -0
  57. package/src/commands.test.ts +1261 -77
  58. package/src/commands.ts +1482 -375
  59. package/src/config-schema.test.ts +3 -3
  60. package/src/config-schema.ts +57 -44
  61. package/src/config.test.ts +22 -16
  62. package/src/config.ts +81 -66
  63. package/src/file-lock-process-worker.test.ts +64 -0
  64. package/src/file-lock.test.ts +279 -0
  65. package/src/file-lock.ts +318 -0
  66. package/src/harnesses.test.ts +34 -0
  67. package/src/harnesses.ts +57 -0
  68. package/src/hook-adapter.test.ts +187 -0
  69. package/src/hook-adapter.ts +212 -0
  70. package/src/hook-input.test.ts +39 -0
  71. package/src/hook-input.ts +72 -0
  72. package/src/hooks.test.ts +1985 -417
  73. package/src/hooks.ts +1338 -461
  74. package/src/install-hooks.test.ts +131 -41
  75. package/src/install-hooks.ts +284 -84
  76. package/src/interactive.ts +3 -8
  77. package/src/logging-process-worker.test.ts +39 -0
  78. package/src/logging.test.ts +453 -0
  79. package/src/logging.ts +639 -0
  80. package/src/main.ts +113 -32
  81. package/src/opencode-plugin.ts +15 -16
  82. package/src/session-end-log-lock-worker.test.ts +35 -0
  83. package/src/skill.test.ts +34 -0
  84. package/src/ui/config-view.test.ts +9 -7
  85. package/src/ui/config-view.ts +3 -0
  86. package/src/idle.test.ts +0 -32
  87. package/src/idle.ts +0 -41
@@ -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,
@@ -11,6 +12,7 @@ import type {
11
12
  } from '@raidiant/notifai-protocol'
12
13
  import { describe, expect, it } from 'vitest'
13
14
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
15
+ import type { ClaudeWakeAdapters } from './claude-wake.js'
14
16
  import {
15
17
  askCommand,
16
18
  accessStatusCommand,
@@ -20,6 +22,7 @@ import {
20
22
  configExplainCommand,
21
23
  configSetCommand,
22
24
  configShowCommand,
25
+ configUnsetCommand,
23
26
  contradictingAnswer,
24
27
  describeHookFailure,
25
28
  doctorCommand,
@@ -29,6 +32,8 @@ import {
29
32
  initCommand,
30
33
  SKILLS_SOURCE,
31
34
  loginCommand,
35
+ logsCommand,
36
+ parseSince,
32
37
  projectSlugFrom,
33
38
  repliesCommand,
34
39
  sendCommand,
@@ -37,10 +42,18 @@ import {
37
42
  type CommandIo,
38
43
  type CommandSpinner,
39
44
  } from './commands.js'
40
- import { applyPlan, buildHookConfig } from './install-hooks.js'
45
+ import {
46
+ applyPlan,
47
+ buildHookConfig,
48
+ codexHookIdentityHash,
49
+ codexTrustKey,
50
+ findInstallations,
51
+ } from './install-hooks.js'
41
52
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
42
53
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
43
- import { CONFIG_KEYS } from './config.js'
54
+ import { CONFIG_KEYS, loadConfig } from './config.js'
55
+ import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
56
+ import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
44
57
  import type { Tone } from './ui/theme.js'
45
58
 
46
59
  class CapturedIo implements CommandIo {
@@ -120,7 +133,23 @@ class InteractiveIo extends CapturedIo {
120
133
  }
121
134
  }
122
135
 
136
+ function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
137
+ const installations = findInstallations(cwd, env).filter(
138
+ (installation) => installation.harness === 'codex',
139
+ )
140
+ const sections = installations.flatMap((installation) =>
141
+ installation.handlers.map((handler) => {
142
+ const key = codexTrustKey(installation, handler)
143
+ return `[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(codexHookIdentityHash(handler))}\n`
144
+ }),
145
+ )
146
+ const codexHome = env['CODEX_HOME'] as string
147
+ mkdirSync(codexHome, { recursive: true })
148
+ writeFileSync(path.join(codexHome, 'config.toml'), sections.join('\n'))
149
+ }
150
+
123
151
  function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
152
+ const testRoot = path.join(os.tmpdir(), 'notifai-cli-command-tests')
124
153
  return {
125
154
  io,
126
155
  store: {
@@ -134,12 +163,36 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
134
163
  clear: () => {},
135
164
  describe: () => 'test credential store',
136
165
  },
137
- env: { XDG_CONFIG_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests') },
166
+ env: {
167
+ XDG_CONFIG_HOME: testRoot,
168
+ XDG_STATE_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests-state'),
169
+ },
170
+ hookAdapterHome: path.join(testRoot, 'home'),
138
171
  cwd: os.tmpdir(),
139
172
  clientFactory: () => client,
140
173
  }
141
174
  }
142
175
 
176
+ /**
177
+ * An environment that cannot see the machine running the suite.
178
+ *
179
+ * Harness discovery walks the real home directory whenever the environment
180
+ * names no override, so a developer with Notifai's own global hooks installed
181
+ * was silently running these cases against their own settings file — and a
182
+ * check that failed on that file failed only for them. Every setup case that
183
+ * asserts on readiness therefore gets its own home.
184
+ */
185
+ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
186
+ const home = path.join(cwd, 'home')
187
+ return {
188
+ HOME: home,
189
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
190
+ CODEX_HOME: path.join(home, '.codex'),
191
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
192
+ XDG_STATE_HOME: path.join(cwd, 'state'),
193
+ }
194
+ }
195
+
143
196
  const receipt: SubmissionReceipt = {
144
197
  request_id: 'req_reply_test',
145
198
  replayed: false,
@@ -494,13 +547,19 @@ describe('command contracts', () => {
494
547
  return replyResponse(polls.length === 3 ? [reply] : [])
495
548
  },
496
549
  } as unknown as ApiClient
550
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-direct-reply-log-'))
497
551
  const deps = {
498
552
  ...makeDeps(io, client),
553
+ env: {
554
+ XDG_CONFIG_HOME: path.join(root, 'config'),
555
+ XDG_STATE_HOME: path.join(root, 'state'),
556
+ },
499
557
  now: () => now,
500
558
  sleep: async (milliseconds: number) => {
501
559
  now += milliseconds
502
560
  },
503
561
  }
562
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
504
563
 
505
564
  expect(
506
565
  await sendCommand(deps, {
@@ -513,6 +572,14 @@ describe('command contracts', () => {
513
572
  expect(polls).toHaveLength(3)
514
573
  expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
515
574
  expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
575
+ const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
576
+ expect(received).toHaveLength(1)
577
+ expect(received[0]?.data).toMatchObject({
578
+ request_id: receipt.request_id,
579
+ sequence: reply.seq,
580
+ device: reply.device_name,
581
+ text: reply.text,
582
+ })
516
583
  })
517
584
 
518
585
  it('backs off and retries a transient network error while waiting', async () => {
@@ -549,6 +616,97 @@ describe('command contracts', () => {
549
616
  expect(sleeps).toEqual([250])
550
617
  })
551
618
 
619
+ /**
620
+ * Regression context: Provider Acceptance and Companion Receipt succeeded,
621
+ * then a long-poll returned HTTP 500 internal_error and the blocking wait aborted
622
+ * while the reply window was still open. The answer arrived ~1 minute later
623
+ * and was recovered only on the next turn. Transient server faults must not
624
+ * end the wait; permanent client errors still may.
625
+ */
626
+ it('keeps blocking through a transient internal_error on the replies poll', async () => {
627
+ const io = new CapturedIo()
628
+ let now = 0
629
+ let replyCalls = 0
630
+ const client = {
631
+ submit: async () => receipt,
632
+ replies: async () => {
633
+ replyCalls += 1
634
+ if (replyCalls <= 2) {
635
+ throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
636
+ }
637
+ return replyResponse([reply])
638
+ },
639
+ } as unknown as ApiClient
640
+ const deps = {
641
+ ...makeDeps(io, client),
642
+ now: () => now,
643
+ sleep: async (milliseconds: number) => {
644
+ now += milliseconds
645
+ },
646
+ }
647
+
648
+ expect(
649
+ await sendCommand(deps, {
650
+ title: 'Question',
651
+ body: 'Deploy?',
652
+ reply: true,
653
+ replyTimeout: 30,
654
+ }),
655
+ ).toBe(EXIT.ok)
656
+ expect(replyCalls).toBe(3)
657
+ expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
658
+ expect(io.errLines.join('\n')).not.toContain('internal_error')
659
+ })
660
+
661
+ it('does not treat a permanent replies error as a transient wait fault', async () => {
662
+ const io = new CapturedIo()
663
+ let now = 0
664
+ let replyCalls = 0
665
+ const client = {
666
+ submit: async () => receipt,
667
+ replies: async () => {
668
+ replyCalls += 1
669
+ throw new ApiCallError(404, 'not_found', 'No such request.')
670
+ },
671
+ } as unknown as ApiClient
672
+ const deps: CommandDeps = {
673
+ ...makeDeps(io, client),
674
+ now: () => now,
675
+ sleep: async (milliseconds: number) => {
676
+ now += milliseconds
677
+ },
678
+ }
679
+ deps.env = {
680
+ ...deps.env,
681
+ XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-reply-wait-log-')),
682
+ }
683
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
684
+
685
+ expect(
686
+ await sendCommand(deps, {
687
+ title: 'Question',
688
+ body: 'Deploy?',
689
+ reply: true,
690
+ replyTimeout: 30,
691
+ }),
692
+ ).toBe(EXIT.failed)
693
+ expect(replyCalls).toBe(1)
694
+ expect(io.errLines.join('\n')).toContain('reply wait failed')
695
+ expect(io.errLines.join('\n')).toContain(receipt.request_id)
696
+ expect(io.errLines.join('\n')).toContain('not_found')
697
+ // Receipt was already printed — the durable send is not the failure.
698
+ expect(io.outLines[0]).toContain(receipt.request_id)
699
+ const waitError = readLogRecords(deps.env, { event: ['cli.error'], limit: 20 }).records.find(
700
+ (record) => record.data?.['operation'] === 'reply_wait',
701
+ )
702
+ expect(waitError?.data).toMatchObject({
703
+ kind: 'api',
704
+ request_id: receipt.request_id,
705
+ status: 404,
706
+ code: 'not_found',
707
+ })
708
+ })
709
+
552
710
  it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
553
711
  const io = new CapturedIo()
554
712
  let now = 0
@@ -619,10 +777,18 @@ describe('command contracts', () => {
619
777
  return replyResponse([reply])
620
778
  },
621
779
  } as unknown as ApiClient
780
+ const deps = makeDeps(io, client)
781
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-replies-log-'))
782
+ deps.env = {
783
+ XDG_CONFIG_HOME: path.join(root, 'config'),
784
+ XDG_STATE_HOME: path.join(root, 'state'),
785
+ }
786
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
622
787
 
623
- expect(await repliesCommand(makeDeps(io, client), receipt.request_id, { after: 7 })).toBe(EXIT.ok)
788
+ expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
624
789
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
625
790
  expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
791
+ expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
626
792
  })
627
793
 
628
794
  it('resolves replies --pending through the project session pointer and prints its id', async () => {
@@ -678,6 +844,198 @@ describe('command contracts', () => {
678
844
  expect(io.outLines[1]).toContain('yes, after the migration')
679
845
  expect(io.outLines.join('\n')).toContain('req_second')
680
846
  })
847
+
848
+ it('keeps the failing request identity when one pending reply wait throws', async () => {
849
+ const io = new CapturedIo()
850
+ const client = {
851
+ replies: async (requestId: string) => {
852
+ if (requestId === 'req_failed') {
853
+ throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
854
+ }
855
+ return replyResponse([reply])
856
+ },
857
+ } as unknown as ApiClient
858
+ const deps = makeDeps(io, client)
859
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-error-log-'))
860
+ deps.cwd = root
861
+ deps.env = {
862
+ XDG_CONFIG_HOME: path.join(root, 'config'),
863
+ XDG_STATE_HOME: path.join(root, 'state'),
864
+ }
865
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
866
+ writeSessionState('pending-error-log', deps.env, {
867
+ pending: [
868
+ { question: 'First?', request_id: 'req_healthy' },
869
+ { question: 'Second?', request_id: 'req_failed' },
870
+ { question: 'Third?', request_id: 'req_unreached' },
871
+ ],
872
+ })
873
+ writeProjectSession(root, deps.env, 'pending-error-log', Date.now(), 'codex')
874
+
875
+ expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.failed)
876
+
877
+ io.outLines = []
878
+ io.errLines = []
879
+ expect(logsCommand(deps, { request: 'req_failed', json: true, allProjects: true })).toBe(
880
+ EXIT.ok,
881
+ )
882
+ expect(io.outLines).toHaveLength(1)
883
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
884
+ event: 'cli.error',
885
+ data: { request_id: 'req_failed', operation: 'reply_wait' },
886
+ })
887
+
888
+ io.outLines = []
889
+ expect(logsCommand(deps, { request: 'req_healthy', json: true, allProjects: true })).toBe(
890
+ EXIT.ok,
891
+ )
892
+ expect(io.outLines).toHaveLength(1)
893
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
894
+ event: 'reply.received',
895
+ data: { request_id: 'req_healthy' },
896
+ })
897
+
898
+ io.outLines = []
899
+ expect(logsCommand(deps, { request: 'req_unreached', json: true, allProjects: true })).toBe(
900
+ EXIT.ok,
901
+ )
902
+ expect(io.outLines).toEqual([])
903
+ })
904
+
905
+ it('attributes a degraded multi-request wait only to the request whose polls failed', async () => {
906
+ const io = new CapturedIo()
907
+ let now = 0
908
+ let failedPolls = 0
909
+ const client = {
910
+ replies: async (requestId: string) => {
911
+ if (requestId !== 'req_degraded') return replyResponse([reply])
912
+ failedPolls += 1
913
+ if (failedPolls === 1) return replyResponse([])
914
+ throw new NetworkError('temporary disconnect')
915
+ },
916
+ } as unknown as ApiClient
917
+ const deps = makeDeps(io, client)
918
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-degraded-log-'))
919
+ deps.cwd = root
920
+ deps.env = {
921
+ XDG_CONFIG_HOME: path.join(root, 'config'),
922
+ XDG_STATE_HOME: path.join(root, 'state'),
923
+ }
924
+ deps.now = () => now
925
+ deps.sleep = async (milliseconds: number) => {
926
+ now += milliseconds
927
+ }
928
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
929
+ writeSessionState('pending-degraded-log', deps.env, {
930
+ pending: [
931
+ { question: 'First?', request_id: 'req_healthy_before' },
932
+ { question: 'Second?', request_id: 'req_degraded' },
933
+ { question: 'Third?', request_id: 'req_healthy_after' },
934
+ ],
935
+ })
936
+ writeProjectSession(root, deps.env, 'pending-degraded-log', Date.now(), 'codex')
937
+
938
+ expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.network)
939
+
940
+ io.outLines = []
941
+ io.errLines = []
942
+ expect(logsCommand(deps, { request: 'req_degraded', json: true, allProjects: true })).toBe(
943
+ EXIT.ok,
944
+ )
945
+ expect(io.outLines).toHaveLength(1)
946
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
947
+ event: 'cli.error',
948
+ data: { request_ids: ['req_degraded'], operation: 'reply_wait' },
949
+ })
950
+
951
+ for (const requestId of ['req_healthy_before', 'req_healthy_after']) {
952
+ io.outLines = []
953
+ expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
954
+ EXIT.ok,
955
+ )
956
+ expect(io.outLines).toHaveLength(1)
957
+ expect(JSON.parse(io.outLines[0]!)).toMatchObject({
958
+ event: 'reply.received',
959
+ data: { request_id: requestId },
960
+ })
961
+ }
962
+ })
963
+
964
+ it('records earlier degraded requests before a later pending wait throws', async () => {
965
+ const io = new CapturedIo()
966
+ let now = 0
967
+ let degradedPolls = 0
968
+ const client = {
969
+ replies: async (requestId: string) => {
970
+ if (requestId === 'req_success') return replyResponse([reply])
971
+ if (requestId === 'req_degraded') {
972
+ degradedPolls += 1
973
+ if (degradedPolls === 1) return replyResponse([])
974
+ throw new NetworkError('temporary disconnect')
975
+ }
976
+ if (requestId === 'req_failed') {
977
+ throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
978
+ }
979
+ return replyResponse([reply])
980
+ },
981
+ } as unknown as ApiClient
982
+ const deps = makeDeps(io, client)
983
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-mixed-log-'))
984
+ deps.cwd = root
985
+ deps.env = {
986
+ XDG_CONFIG_HOME: path.join(root, 'config'),
987
+ XDG_STATE_HOME: path.join(root, 'state'),
988
+ }
989
+ deps.now = () => now
990
+ deps.sleep = async (milliseconds: number) => {
991
+ now += milliseconds
992
+ }
993
+ deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
994
+ writeSessionState('pending-mixed-log', deps.env, {
995
+ pending: [
996
+ { question: 'Healthy?', request_id: 'req_success' },
997
+ { question: 'Degraded?', request_id: 'req_degraded' },
998
+ { question: 'Gone?', request_id: 'req_failed' },
999
+ { question: 'Unreached?', request_id: 'req_unreached' },
1000
+ ],
1001
+ })
1002
+ writeProjectSession(root, deps.env, 'pending-mixed-log', Date.now(), 'codex')
1003
+
1004
+ expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.failed)
1005
+
1006
+ const recordsFor = (requestId: string): Array<Record<string, unknown>> => {
1007
+ io.outLines = []
1008
+ io.errLines = []
1009
+ expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
1010
+ EXIT.ok,
1011
+ )
1012
+ return io.outLines.map((line) => JSON.parse(line) as Record<string, unknown>)
1013
+ }
1014
+
1015
+ expect(recordsFor('req_success')).toEqual([
1016
+ expect.objectContaining({
1017
+ event: 'reply.received',
1018
+ data: expect.objectContaining({ request_id: 'req_success' }),
1019
+ }),
1020
+ ])
1021
+ expect(recordsFor('req_degraded')).toEqual([
1022
+ expect.objectContaining({
1023
+ event: 'cli.error',
1024
+ data: expect.objectContaining({
1025
+ request_ids: ['req_degraded'],
1026
+ operation: 'reply_wait',
1027
+ degraded: true,
1028
+ }),
1029
+ }),
1030
+ ])
1031
+ expect(recordsFor('req_failed')).toEqual([
1032
+ expect.objectContaining({
1033
+ event: 'cli.error',
1034
+ data: expect.objectContaining({ request_id: 'req_failed', operation: 'reply_wait' }),
1035
+ }),
1036
+ ])
1037
+ expect(recordsFor('req_unreached')).toEqual([])
1038
+ })
681
1039
  })
682
1040
 
683
1041
  describe('delivery evidence status', () => {
@@ -734,6 +1092,8 @@ describe('delivery evidence status', () => {
734
1092
  expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
735
1093
  expect(said).toContain('not a failure')
736
1094
  expect(said).toContain('attempt_started')
1095
+ expect(said).toContain('OS presentation: not observed by Notifai')
1096
+ expect(said).toContain('Reply wait:')
737
1097
  })
738
1098
 
739
1099
  it('reports the observed device receipt and measured provider-to-companion latency', async () => {
@@ -751,12 +1111,40 @@ describe('delivery evidence status', () => {
751
1111
  const said = io.outLines.join('\n')
752
1112
  expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
753
1113
  expect(said).toContain('11m 27s after Provider Acceptance')
1114
+ expect(said).toContain('OS presentation: not observed by Notifai')
1115
+ expect(said).toContain('Reply received: not yet recorded')
1116
+ expect(said).toContain('Reply wait: no answer stored yet')
1117
+ })
1118
+
1119
+ it('separates a stored reply from Delivery so a wait fault is not misread as non-delivery', async () => {
1120
+ const io = new CapturedIo()
1121
+ const base = snapshot({
1122
+ state: 'observed',
1123
+ observed_at: '2026-08-05T13:17:17.000Z',
1124
+ latency_ms: 1_000,
1125
+ })
1126
+ base.deliveries[0]!.events.push({
1127
+ stage: 'reply_received',
1128
+ source: 'companion',
1129
+ reason: null,
1130
+ attempt: null,
1131
+ occurred_at: '2026-08-05T13:18:00.000Z',
1132
+ })
1133
+ const client = { evidence: async () => base } as unknown as ApiClient
1134
+
1135
+ expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
1136
+ const said = io.outLines.join('\n')
1137
+ expect(said).toContain('Provider Acceptance: accepted')
1138
+ expect(said).toContain('Companion Receipt')
1139
+ expect(said).toContain('OS presentation: not observed by Notifai')
1140
+ expect(said).toContain('Reply received: yes')
1141
+ expect(said).toContain('answers are on the server')
754
1142
  })
755
1143
  })
756
1144
 
757
1145
  describe('Cursor hook commands', () => {
758
- const execPath = '/usr/local/bin/node'
759
- const scriptPath = '/opt/notifai/dist/main.js'
1146
+ const execPath = process.execPath
1147
+ const scriptPath = fileURLToPath(import.meta.url)
760
1148
 
761
1149
  it('installs native Cursor hooks with bounded chained answer continuations', () => {
762
1150
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
@@ -850,8 +1238,8 @@ describe('Cursor hook commands', () => {
850
1238
  })
851
1239
 
852
1240
  describe('harness activation guidance', () => {
853
- const execPath = '/usr/local/bin/node'
854
- const scriptPath = '/opt/notifai/dist/main.js'
1241
+ const execPath = process.execPath
1242
+ const scriptPath = fileURLToPath(import.meta.url)
855
1243
 
856
1244
  it('does not require a Claude Code restart for project hook files', () => {
857
1245
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
@@ -881,7 +1269,7 @@ describe('harness activation guidance', () => {
881
1269
  expect(output).not.toMatch(/trust|approve/i)
882
1270
  })
883
1271
 
884
- it('keeps OpenCode permission prompts local and reports its continuation limit', async () => {
1272
+ it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
885
1273
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
886
1274
  const io = new CapturedIo()
887
1275
  const client = {
@@ -889,21 +1277,33 @@ describe('harness activation guidance', () => {
889
1277
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
890
1278
  listDevices: async () => ({ devices: [] }),
891
1279
  } as unknown as ApiClient
892
- const deps = { ...makeDeps(io, client), cwd }
1280
+ const base = makeDeps(io, client)
1281
+ const deps = {
1282
+ ...base,
1283
+ cwd,
1284
+ env: {
1285
+ ...base.env,
1286
+ HOME: path.join(cwd, 'home'),
1287
+ CODEX_HOME: path.join(cwd, 'codex-home'),
1288
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
1289
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
1290
+ },
1291
+ }
893
1292
 
894
1293
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
895
1294
  EXIT.ok,
896
1295
  )
897
1296
 
898
1297
  expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
899
- expect(io.outLines.join('\n')).toContain('next user prompt')
1298
+ expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
900
1299
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
901
1300
  const plugin = readFileSync(pluginFile, 'utf8')
902
1301
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
903
1302
 
904
1303
  io.outLines = []
905
1304
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
906
- expect(io.outLines.join('\n')).toContain('hooks (opencode continuation)')
1305
+ expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
1306
+ expect(io.outLines.join('\n')).toContain('no proven answer continuation')
907
1307
  expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
908
1308
 
909
1309
  writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
@@ -912,6 +1312,107 @@ describe('harness activation guidance', () => {
912
1312
  expect(io.outLines.join('\n')).toContain('hooks (adapter)')
913
1313
  expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
914
1314
  })
1315
+
1316
+ it('refuses a symlinked OpenCode plugin without touching its target', () => {
1317
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-symlink-'))
1318
+ const plugin = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
1319
+ const target = path.join(cwd, 'foreign.js')
1320
+ mkdirSync(path.dirname(plugin), { recursive: true })
1321
+ writeFileSync(target, '// notifai managed opencode plugin\nleave me\n')
1322
+ symlinkSync(target, plugin)
1323
+ const io = new CapturedIo()
1324
+ const deps = {
1325
+ ...makeDeps(io, {} as ApiClient),
1326
+ cwd,
1327
+ env: { XDG_STATE_HOME: path.join(cwd, 'state') },
1328
+ }
1329
+
1330
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
1331
+ EXIT.failed,
1332
+ )
1333
+ expect(io.errLines.join('\n')).toMatch(/not a regular file/)
1334
+ expect(readFileSync(target, 'utf8')).toBe('// notifai managed opencode plugin\nleave me\n')
1335
+ // Adapter preparation commits first. A definition failure leaves a valid
1336
+ // shared adapter (useful to any existing harness) and never half-writes the
1337
+ // rejected definition.
1338
+ expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
1339
+ expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
1340
+ })
1341
+ })
1342
+
1343
+ describe('stable hook installation', () => {
1344
+ it('keeps definition bytes stable across config, CLI target, and project/global changes', async () => {
1345
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-stable-hook-install-'))
1346
+ const firstCli = path.join(cwd, 'first cli.js')
1347
+ const secondCli = path.join(cwd, 'second cli.js')
1348
+ writeFileSync(firstCli, '')
1349
+ writeFileSync(secondCli, '')
1350
+ const io = new CapturedIo()
1351
+ const env = {
1352
+ HOME: path.join(cwd, 'home'),
1353
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
1354
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1355
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
1356
+ CODEX_HOME: path.join(cwd, 'codex-home'),
1357
+ }
1358
+ const client = {
1359
+ health: async () => true,
1360
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1361
+ listDevices: async () => ({ devices: [] }),
1362
+ } as unknown as ApiClient
1363
+ const deps = { ...makeDeps(io, client), cwd, env }
1364
+ const local = path.join(cwd, '.claude', 'settings.local.json')
1365
+
1366
+ expect(
1367
+ hooksInstallCommand(deps, {
1368
+ harness: 'claude-code',
1369
+ execPath: process.execPath,
1370
+ scriptPath: firstCli,
1371
+ }),
1372
+ ).toBe(EXIT.ok)
1373
+ const firstDefinition = readFileSync(local, 'utf8')
1374
+ const firstAdapter = readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')
1375
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
1376
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
1377
+ env.XDG_CONFIG_HOME = path.join(cwd, 'different-config')
1378
+ env.XDG_STATE_HOME = path.join(cwd, 'different-state')
1379
+
1380
+ expect(
1381
+ hooksInstallCommand(deps, {
1382
+ harness: 'claude-code',
1383
+ execPath: process.execPath,
1384
+ scriptPath: secondCli,
1385
+ }),
1386
+ ).toBe(EXIT.ok)
1387
+ expect(readFileSync(local, 'utf8')).toBe(firstDefinition)
1388
+ expect(readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')).not.toBe(firstAdapter)
1389
+ expect(firstDefinition).toContain(hookAdapterPath(deps.hookAdapterHome))
1390
+ expect(firstDefinition).not.toContain(firstCli)
1391
+ expect(firstDefinition).not.toContain(secondCli)
1392
+
1393
+ expect(
1394
+ hooksInstallCommand(deps, {
1395
+ harness: 'claude-code',
1396
+ global: true,
1397
+ execPath: process.execPath,
1398
+ scriptPath: secondCli,
1399
+ }),
1400
+ ).toBe(EXIT.ok)
1401
+ const global = readFileSync(path.join(env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')
1402
+ expect(global).toBe(firstDefinition)
1403
+
1404
+ io.outLines = []
1405
+ await doctorCommand(deps, {})
1406
+ expect(io.outLines.join('\n')).toMatch(/hooks \(duplicates\).*project or global routing/is)
1407
+
1408
+ expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
1409
+ expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
1410
+ expect(
1411
+ findInstallations(cwd, env, deps.hookAdapterHome).filter(
1412
+ (item) => item.harness === 'claude-code',
1413
+ ),
1414
+ ).toHaveLength(1)
1415
+ })
915
1416
  })
916
1417
 
917
1418
  describe('projectSlugFrom', () => {
@@ -923,15 +1424,165 @@ describe('projectSlugFrom', () => {
923
1424
  })
924
1425
  })
925
1426
 
926
- describe('config surfaces', () => {
927
- function configDeps(io: CapturedIo): CommandDeps {
928
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
929
- return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
1427
+ describe('logs', () => {
1428
+ function logDeps(io: CapturedIo): CommandDeps {
1429
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-logs-cmd-'))
1430
+ return {
1431
+ ...makeDeps(io, {} as ApiClient),
1432
+ cwd,
1433
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg'), XDG_STATE_HOME: path.join(cwd, 'state') },
1434
+ }
930
1435
  }
931
1436
 
932
- it('gives an agent the same flat key = value output it always had', () => {
933
- // The whole point of gating the readable rendering on a human terminal.
934
- // Anything parsing this today must keep parsing it tomorrow.
1437
+ function seed(deps: CommandDeps, project?: string): void {
1438
+ const logger = createLogger({ env: deps.env, runId: 'r_seed', cmd: 'send' })
1439
+ if (project !== undefined) logger.bind({ project })
1440
+ logger.info('send.submitted', { request_id: 'req_seeded' })
1441
+ logger.error('cli.error', { message: 'the server said no' })
1442
+ }
1443
+
1444
+ it('shows the recent records, newest last', () => {
1445
+ const io = new CapturedIo()
1446
+ const deps = logDeps(io)
1447
+ seed(deps)
1448
+ expect(logsCommand(deps, {})).toBe(EXIT.ok)
1449
+ expect(io.outLines).toHaveLength(2)
1450
+ expect(io.outLines[0]).toContain('send.submitted')
1451
+ expect(io.outLines[1]).toContain('cli.error')
1452
+ })
1453
+
1454
+ it('keeps stdout pure JSONL under --json so a parser needs no filtering', () => {
1455
+ // Every word of explanation goes to stderr. A machine reading stdout gets
1456
+ // records and nothing else.
1457
+ const io = new CapturedIo()
1458
+ const deps = logDeps(io)
1459
+ seed(deps)
1460
+ expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
1461
+ for (const line of io.outLines) {
1462
+ expect(() => JSON.parse(line) as unknown).not.toThrow()
1463
+ }
1464
+ expect(io.outLines).toHaveLength(2)
1465
+ })
1466
+
1467
+ it('narrows to the failures', () => {
1468
+ const io = new CapturedIo()
1469
+ const deps = logDeps(io)
1470
+ seed(deps)
1471
+ expect(logsCommand(deps, { level: 'error', json: true })).toBe(EXIT.ok)
1472
+ expect(io.outLines).toHaveLength(1)
1473
+ expect(io.outLines[0]).toContain('cli.error')
1474
+ })
1475
+
1476
+ it('narrows to one notification request', () => {
1477
+ const io = new CapturedIo()
1478
+ const deps = logDeps(io)
1479
+ seed(deps)
1480
+ expect(logsCommand(deps, { request: 'req_seeded', json: true })).toBe(EXIT.ok)
1481
+ expect(io.outLines).toHaveLength(1)
1482
+ })
1483
+
1484
+ it('scopes to this project unless told otherwise', () => {
1485
+ const io = new CapturedIo()
1486
+ const deps = logDeps(io)
1487
+ mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
1488
+ writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'project = "mine"\n')
1489
+ seed(deps, 'mine')
1490
+ seed(deps, 'someone-elses')
1491
+
1492
+ // A machine commonly runs several agents in several worktrees at once, and
1493
+ // an answer interleaved with three other projects answers nothing.
1494
+ expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
1495
+ expect(io.outLines).toHaveLength(2)
1496
+ io.outLines.length = 0
1497
+ expect(logsCommand(deps, { json: true, allProjects: true })).toBe(EXIT.ok)
1498
+ expect(io.outLines).toHaveLength(4)
1499
+ })
1500
+
1501
+ it('rejects an unreadable time, unknown event, and invalid limits', () => {
1502
+ const io = new CapturedIo()
1503
+ const deps = logDeps(io)
1504
+ expect(logsCommand(deps, { since: 'yesterday-ish' })).toBe(EXIT.usage)
1505
+ expect(io.errLines.join('\n')).toContain('10m')
1506
+ io.errLines.length = 0
1507
+ expect(logsCommand(deps, { event: ['send.exploded'] })).toBe(EXIT.usage)
1508
+ expect(io.errLines.join('\n')).toContain('send.submitted')
1509
+ io.errLines.length = 0
1510
+ expect(logsCommand(deps, { limit: Number.NaN })).toBe(EXIT.usage)
1511
+ expect(io.errLines.join('\n')).toContain('--limit')
1512
+ io.errLines.length = 0
1513
+ expect(logsCommand(deps, { limit: 2_001 })).toBe(EXIT.usage)
1514
+ expect(io.errLines.join('\n')).toContain('cannot exceed 2000')
1515
+ })
1516
+
1517
+ it.each([
1518
+ [{ path: true, clear: true }, '--path'],
1519
+ [{ path: true, request: 'req_seeded' }, '--path'],
1520
+ [{ clear: true, since: '1d' }, '--clear'],
1521
+ [{ project: 'mine', allProjects: true }, '--project'],
1522
+ [{ limit: 10, all: true }, '--limit'],
1523
+ ] as const)('rejects incompatible log options %j', (flags, namedFlag) => {
1524
+ const io = new CapturedIo()
1525
+ expect(logsCommand(logDeps(io), flags)).toBe(EXIT.usage)
1526
+ expect(io.errLines.join('\n')).toContain(namedFlag)
1527
+ })
1528
+
1529
+ it('accepts relative and absolute times', () => {
1530
+ const now = Date.parse('2026-08-11T12:00:00Z')
1531
+ expect(parseSince('10m', now)).toBe(now - 600_000)
1532
+ expect(parseSince('2h', now)).toBe(now - 7_200_000)
1533
+ expect(parseSince('1d', now)).toBe(now - 86_400_000)
1534
+ expect(parseSince('2026-08-11T11:00:00Z', now)).toBe(Date.parse('2026-08-11T11:00:00Z'))
1535
+ expect(parseSince('whenever', now)).toBeNull()
1536
+ })
1537
+
1538
+ it('says why it is empty, and what to do about it', () => {
1539
+ // "No records" with no explanation sends the reader looking for a bug that
1540
+ // is actually a setting.
1541
+ const io = new CapturedIo()
1542
+ const deps = logDeps(io)
1543
+ mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
1544
+ writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'log_level = "off"\n')
1545
+ expect(logsCommand(deps, {})).toBe(EXIT.ok)
1546
+ expect(io.errLines.join('\n')).toContain('log_level is off')
1547
+ expect(io.errLines.join('\n')).toContain('config set log_level info')
1548
+ })
1549
+
1550
+ it('points at the files, and says they stay here', () => {
1551
+ const io = new CapturedIo()
1552
+ const deps = logDeps(io)
1553
+ seed(deps)
1554
+ expect(logsCommand(deps, { path: true })).toBe(EXIT.ok)
1555
+ expect(io.outLines[0]).toContain('notifai.jsonl')
1556
+ expect(io.errLines.join('\n')).toContain('stay on this machine')
1557
+ })
1558
+
1559
+ it('reports local_only in the machine-readable form', () => {
1560
+ const io = new CapturedIo()
1561
+ const deps = logDeps(io)
1562
+ expect(logsCommand(deps, { path: true, json: true })).toBe(EXIT.ok)
1563
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
1564
+ expect(parsed['local_only']).toBe(true)
1565
+ expect(parsed['level']).toBe('info')
1566
+ })
1567
+
1568
+ it('clears the files when asked', () => {
1569
+ const io = new CapturedIo()
1570
+ const deps = logDeps(io)
1571
+ seed(deps)
1572
+ expect(logsCommand(deps, { clear: true })).toBe(EXIT.ok)
1573
+ expect(logsDiskUsage(deps.env).files).toBe(0)
1574
+ })
1575
+ })
1576
+
1577
+ describe('config surfaces', () => {
1578
+ function configDeps(io: CapturedIo): CommandDeps {
1579
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
1580
+ return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
1581
+ }
1582
+
1583
+ it('gives an agent the same flat key = value output it always had', () => {
1584
+ // The whole point of gating the readable rendering on a human terminal.
1585
+ // Anything parsing this today must keep parsing it tomorrow.
935
1586
  const io = new CapturedIo()
936
1587
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
937
1588
  expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
@@ -943,8 +1594,8 @@ describe('config surfaces', () => {
943
1594
  const io = new InteractiveIo()
944
1595
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
945
1596
  const text = io.outLines.join('\n')
946
- expect(text).toContain('Questions & presence')
947
- expect(text).toContain('Whether sitting at this keyboard holds a question back')
1597
+ expect(text).toContain('Questions')
1598
+ expect(text).toContain('Optional delay before a question may reach your devices')
948
1599
  })
949
1600
 
950
1601
  it('keeps --plain available to a human who wants the parseable form', () => {
@@ -955,17 +1606,18 @@ describe('config surfaces', () => {
955
1606
 
956
1607
  it('explains one setting, and says so in JSON when asked', () => {
957
1608
  const io = new InteractiveIo()
958
- expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
1609
+ expect(configExplainCommand(configDeps(io), 'ask_notifications', { json: true })).toBe(EXIT.ok)
959
1610
  const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
960
- expect(parsed['key']).toBe('require_idle')
1611
+ expect(parsed['key']).toBe('ask_notifications')
961
1612
  expect(parsed['accepts']).toBe('true or false')
962
- expect(parsed['detail']).toContain('question waits while you are using this machine')
1613
+ expect(parsed['detail']).toContain('The master switch')
1614
+ expect(parsed['detail']).toContain('Turn it off')
963
1615
  })
964
1616
 
965
1617
  it('rejects an unknown setting and points at the nearest real one', () => {
966
1618
  const io = new CapturedIo()
967
- expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
968
- expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
1619
+ expect(configExplainCommand(configDeps(io), 'ask_notification')).toBe(EXIT.usage)
1620
+ expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
969
1621
  })
970
1622
 
971
1623
  it('refuses an enum value the sender would later reject', async () => {
@@ -982,6 +1634,49 @@ describe('config surfaces', () => {
982
1634
  const io = new CapturedIo()
983
1635
  expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
984
1636
  })
1637
+
1638
+ it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
1639
+ const io = new CapturedIo()
1640
+ const deps = configDeps(io)
1641
+ const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
1642
+ mkdirSync(path.dirname(configFile), { recursive: true })
1643
+ writeFileSync(configFile, 'wait_seconds = 20\nsound = "done"\n')
1644
+
1645
+ expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
1646
+
1647
+ const remaining = readFileSync(configFile, 'utf8')
1648
+ expect(remaining).toContain('sound = "done"')
1649
+ expect(remaining).not.toContain('wait_seconds')
1650
+ const resolved = loadConfig({ cwd: deps.cwd, env: deps.env })
1651
+ expect(resolved.wait_seconds).toEqual({ value: 10, source: 'default' })
1652
+ expect(resolved.sound.source).toMatch(/^global:/)
1653
+ })
1654
+
1655
+ it('refuses to create a redundant machine override equal to the shipped default', async () => {
1656
+ const io = new CapturedIo()
1657
+ const deps = configDeps(io)
1658
+
1659
+ expect(await configSetCommand(deps, 'wait_seconds', '10', { yes: true })).toBe(EXIT.usage)
1660
+ expect(io.errLines.join('\n')).toContain('already the shipped default')
1661
+ expect(io.errLines.join('\n')).toContain('notifai config unset wait_seconds')
1662
+ expect(existsSync(path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml'))).toBe(false)
1663
+ })
1664
+
1665
+ it('allows a project to choose the shipped value when it masks a machine override', async () => {
1666
+ const io = new CapturedIo()
1667
+ const deps = configDeps(io)
1668
+ const globalFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
1669
+ mkdirSync(path.dirname(globalFile), { recursive: true })
1670
+ writeFileSync(globalFile, 'wait_seconds = 20\n')
1671
+
1672
+ expect(await configSetCommand(deps, 'wait_seconds', '10', { project: true, yes: true })).toBe(
1673
+ EXIT.ok,
1674
+ )
1675
+ expect(loadConfig({ cwd: deps.cwd, env: deps.env }).wait_seconds).toMatchObject({
1676
+ value: 10,
1677
+ source: expect.stringMatching(/^project:/),
1678
+ })
1679
+ })
985
1680
  })
986
1681
 
987
1682
  describe('interactive command UX', () => {
@@ -1099,6 +1794,25 @@ describe('interactive command UX', () => {
1099
1794
  )
1100
1795
  })
1101
1796
 
1797
+ it('asks the same layer question when returning a setting to its inherited value', async () => {
1798
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
1799
+ const io = new InteractiveIo()
1800
+ io.selectAnswer = 'local'
1801
+ const localFile = path.join(cwd, '.notifai', 'config.local.toml')
1802
+ mkdirSync(path.dirname(localFile), { recursive: true })
1803
+ writeFileSync(localFile, 'sound = "done"\n')
1804
+ const deps = {
1805
+ ...makeDeps(io, {} as ApiClient),
1806
+ cwd,
1807
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
1808
+ }
1809
+
1810
+ expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
1811
+ expect(io.prompts[0]).toBe('Where should this setting live?')
1812
+ expect(io.prompts[1]).toContain(localFile)
1813
+ expect(existsSync(localFile)).toBe(false)
1814
+ })
1815
+
1102
1816
  it('rejects numeric config values that resolution would otherwise silently clamp', async () => {
1103
1817
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-bounds-'))
1104
1818
  const io = new CapturedIo()
@@ -1116,10 +1830,10 @@ describe('interactive command UX', () => {
1116
1830
  EXIT.usage,
1117
1831
  )
1118
1832
  expect(io.errLines).toEqual([
1119
- 'ask_grace_seconds must be between 0 and 540.',
1833
+ 'ask_grace_seconds must be between 0 and 360.',
1120
1834
  // Names the key and its range: `"1.5" is not an integer` left the reader
1121
1835
  // to work out which of the two settings they had just mistyped.
1122
- 'ask_grace_seconds takes a whole number from 0s–540s, not "1.5".',
1836
+ 'ask_grace_seconds takes a whole number from 0s–360s, not "1.5".',
1123
1837
  ])
1124
1838
  expect(existsSync(configFile)).toBe(false)
1125
1839
  })
@@ -1260,7 +1974,7 @@ describe('init', () => {
1260
1974
  source: 'Raidiant-io/notifai',
1261
1975
  sourceType: 'github',
1262
1976
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1263
- ref: 'v0.3.0',
1977
+ ref: 'v0.5.0',
1264
1978
  }
1265
1979
  }
1266
1980
 
@@ -1288,7 +2002,7 @@ describe('init', () => {
1288
2002
  return {
1289
2003
  ...makeDeps(io, client),
1290
2004
  cwd,
1291
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2005
+ env: isolatedEnv(cwd),
1292
2006
  nativeSkills,
1293
2007
  }
1294
2008
  }
@@ -1363,7 +2077,7 @@ describe('init', () => {
1363
2077
  })
1364
2078
 
1365
2079
  it('pins the skill installer to the tagged public release syntax', () => {
1366
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.3.0')
2080
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.5.0')
1367
2081
  expect(SKILLS_SOURCE).not.toContain('@v')
1368
2082
  })
1369
2083
 
@@ -1386,7 +2100,7 @@ describe('init', () => {
1386
2100
  source: 'Raidiant-io/notifai',
1387
2101
  sourceType: 'github',
1388
2102
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1389
- ref: 'v0.3.0',
2103
+ ref: 'v0.5.0',
1390
2104
  },
1391
2105
  ],
1392
2106
  }),
@@ -1618,7 +2332,7 @@ describe('init', () => {
1618
2332
  const deps: CommandDeps = {
1619
2333
  ...makeDeps(io, { health: async () => true } as unknown as ApiClient),
1620
2334
  cwd,
1621
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2335
+ env: isolatedEnv(cwd),
1622
2336
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
1623
2337
  }
1624
2338
 
@@ -1643,7 +2357,7 @@ describe('init', () => {
1643
2357
  const deps = {
1644
2358
  ...makeDeps(io, client),
1645
2359
  cwd,
1646
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2360
+ env: isolatedEnv(cwd),
1647
2361
  }
1648
2362
 
1649
2363
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -1716,7 +2430,7 @@ describe('init', () => {
1716
2430
  const deps = {
1717
2431
  ...makeDeps(io, client),
1718
2432
  cwd,
1719
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2433
+ env: isolatedEnv(cwd),
1720
2434
  }
1721
2435
 
1722
2436
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -1758,7 +2472,7 @@ describe('init', () => {
1758
2472
  const deps: CommandDeps = {
1759
2473
  ...makeDeps(io, client),
1760
2474
  cwd,
1761
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2475
+ env: isolatedEnv(cwd),
1762
2476
  now: () => now,
1763
2477
  sleep: async (milliseconds) => {
1764
2478
  now += milliseconds
@@ -1824,7 +2538,7 @@ describe('init', () => {
1824
2538
  const deps: CommandDeps = {
1825
2539
  ...makeDeps(io, client),
1826
2540
  cwd,
1827
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2541
+ env: isolatedEnv(cwd),
1828
2542
  now: () => now,
1829
2543
  sleep: async (milliseconds) => {
1830
2544
  now += milliseconds
@@ -1864,7 +2578,7 @@ describe('init', () => {
1864
2578
  const deps = {
1865
2579
  ...makeDeps(io, client),
1866
2580
  cwd,
1867
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2581
+ env: isolatedEnv(cwd),
1868
2582
  }
1869
2583
 
1870
2584
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -1908,7 +2622,7 @@ describe('init', () => {
1908
2622
  const deps: CommandDeps = {
1909
2623
  ...makeDeps(io, client),
1910
2624
  cwd,
1911
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2625
+ env: isolatedEnv(cwd),
1912
2626
  now: () => now,
1913
2627
  sleep: async (milliseconds) => {
1914
2628
  now += milliseconds
@@ -1954,7 +2668,7 @@ describe('init', () => {
1954
2668
  const deps = {
1955
2669
  ...makeDeps(io, client),
1956
2670
  cwd,
1957
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: '/dev/null' },
2671
+ env: { ...isolatedEnv(cwd), XDG_STATE_HOME: '/dev/null' },
1958
2672
  }
1959
2673
 
1960
2674
  await expect(initCommand(deps, { hooks: false, skills: false })).resolves.toBe(EXIT.failed)
@@ -1981,7 +2695,7 @@ describe('init', () => {
1981
2695
  const deps = {
1982
2696
  ...makeDeps(io, client),
1983
2697
  cwd,
1984
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2698
+ env: isolatedEnv(cwd),
1985
2699
  }
1986
2700
 
1987
2701
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
@@ -1999,7 +2713,7 @@ describe('init', () => {
1999
2713
  {
2000
2714
  ...makeDeps(doctorIo, client),
2001
2715
  cwd,
2002
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2716
+ env: isolatedEnv(cwd),
2003
2717
  },
2004
2718
  {},
2005
2719
  ),
@@ -2059,7 +2773,7 @@ describe('init', () => {
2059
2773
  const deps = {
2060
2774
  ...makeDeps(io, client),
2061
2775
  cwd,
2062
- env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
2776
+ env: isolatedEnv(cwd),
2063
2777
  }
2064
2778
 
2065
2779
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
@@ -2095,7 +2809,7 @@ describe('init', () => {
2095
2809
  const deps = {
2096
2810
  ...makeDeps(io, client),
2097
2811
  cwd,
2098
- env: { XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
2812
+ env: { ...isolatedEnv(cwd), XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
2099
2813
  }
2100
2814
  expect(
2101
2815
  await initCommand(deps, { projectId: 'shared-project', hooks: false, skills: false }),
@@ -2114,7 +2828,9 @@ describe('an outage is not an answer', () => {
2114
2828
  * agent scripted to read exit 3 as "nobody objected" would proceed against a
2115
2829
  * refusal it never saw.
2116
2830
  */
2117
- function outageAfterFirstPoll(io: CapturedIo): CommandDeps {
2831
+ function outageAfterFirstPoll(io: CapturedIo, fault: () => never = () => {
2832
+ throw new NetworkError('link went down')
2833
+ }): CommandDeps {
2118
2834
  let now = 0
2119
2835
  let polls = 0
2120
2836
  const client = {
@@ -2122,7 +2838,7 @@ describe('an outage is not an answer', () => {
2122
2838
  replies: async () => {
2123
2839
  polls += 1
2124
2840
  if (polls === 1) return replyResponse([])
2125
- throw new NetworkError('link went down')
2841
+ fault()
2126
2842
  },
2127
2843
  } as unknown as ApiClient
2128
2844
  return {
@@ -2148,6 +2864,27 @@ describe('an outage is not an answer', () => {
2148
2864
  expect(exit).not.toBe(EXIT.noReply)
2149
2865
  expect(exit).toBe(EXIT.network)
2150
2866
  expect(io.errLines.join('\n')).toContain('could not find out')
2867
+ expect(io.errLines.join('\n')).toContain(receipt.request_id)
2868
+ })
2869
+
2870
+ it('treats a late internal_error outage the same as a network outage', async () => {
2871
+ const io = new CapturedIo()
2872
+ const exit = await sendCommand(
2873
+ outageAfterFirstPoll(io, () => {
2874
+ throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
2875
+ }),
2876
+ {
2877
+ title: 'Question',
2878
+ body: 'Deploy to production?',
2879
+ reply: true,
2880
+ replyTimeout: 10,
2881
+ },
2882
+ )
2883
+
2884
+ expect(exit).not.toBe(EXIT.noReply)
2885
+ expect(exit).toBe(EXIT.network)
2886
+ expect(io.errLines.join('\n')).toContain('could not find out')
2887
+ expect(io.errLines.join('\n')).toContain('Provider Acceptance')
2151
2888
  })
2152
2889
 
2153
2890
  it('marks the JSON so an agent reading it programmatically can tell', async () => {
@@ -2186,8 +2923,8 @@ describe('an outage is not an answer', () => {
2186
2923
  })
2187
2924
 
2188
2925
  describe('asking before the hooks have ever run', () => {
2189
- const execPath = '/usr/local/bin/node'
2190
- const scriptPath = '/opt/notifai/dist/main.js'
2926
+ const execPath = process.execPath
2927
+ const scriptPath = fileURLToPath(import.meta.url)
2191
2928
 
2192
2929
  it('names the active Codex harness instead of unrelated installed adapters', () => {
2193
2930
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
@@ -2230,7 +2967,7 @@ describe('asking before the hooks have ever run', () => {
2230
2967
  io.outLines = []
2231
2968
 
2232
2969
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2233
- expect(io.errLines.join(' ')).toMatch(/belongs to another Codex session/i)
2970
+ expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
2234
2971
  expect(io.outLines).not.toContain(
2235
2972
  'Question registered. Ask it in the conversation as usual and end your turn.',
2236
2973
  )
@@ -2248,16 +2985,230 @@ describe('asking before the hooks have ever run', () => {
2248
2985
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2249
2986
 
2250
2987
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2251
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42 })
2988
+ trustInstalledCodexHooks(cwd, env)
2989
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
2252
2990
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
2991
+ writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
2992
+ writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
2253
2993
  io.outLines = []
2254
2994
 
2255
2995
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2256
2996
  expect(io.outLines).toContain(
2257
- 'Question registered. Ask it in the conversation as usual and end your turn.',
2997
+ 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
2998
+ )
2999
+ })
3000
+
3001
+ // A harness exports its markers into everything it starts, so a nested
3002
+ // harness sees its parent's markers alongside its own. Neither order between
3003
+ // two markers can be right, and both nestings are ordinary: an orchestrator
3004
+ // running inside Claude Code starts Codex, and the reverse happens just as
3005
+ // often. The environment cannot settle it; the pointer index can.
3006
+ it('routes to the live Codex thread when a Claude Code orchestrator started it', () => {
3007
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-under-claude-'))
3008
+ const io = new CapturedIo()
3009
+ const env = {
3010
+ ...isolatedEnv(cwd),
3011
+ // Inherited from the parent Claude Code process, and unstrippable.
3012
+ CLAUDECODE: '1',
3013
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3014
+ CODEX_THREAD_ID: 'codex-current-thread',
3015
+ }
3016
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3017
+
3018
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3019
+ // The configuration that made this unrecoverable: Claude Code hooks
3020
+ // installed machine-wide match every directory, including this one.
3021
+ expect(
3022
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3023
+ ).toBe(EXIT.ok)
3024
+ trustInstalledCodexHooks(cwd, env)
3025
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3026
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3027
+ io.outLines = []
3028
+
3029
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3030
+ expect(readSessionState('codex-current-thread', env).pending?.[0]?.question).toBe('Ship it?')
3031
+ expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
3032
+ })
3033
+
3034
+ it('routes to the live Claude Code session when a Codex orchestrator started it', () => {
3035
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-under-codex-'))
3036
+ const io = new CapturedIo()
3037
+ const env = {
3038
+ ...isolatedEnv(cwd),
3039
+ CODEX_THREAD_ID: 'codex-orchestrator',
3040
+ CLAUDECODE: '1',
3041
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
3042
+ }
3043
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3044
+
3045
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3046
+ EXIT.ok,
3047
+ )
3048
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3049
+ trustInstalledCodexHooks(cwd, env)
3050
+ // Both sessions are live here. The parent fired when its own turn began;
3051
+ // the child fired for the turn that is running this command.
3052
+ writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
3053
+ writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
3054
+ writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
3055
+ writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
3056
+ io.outLines = []
3057
+
3058
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3059
+ expect(readSessionState('claude-current', env).pending?.[0]?.question).toBe('Ship it?')
3060
+ expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
3061
+ })
3062
+
3063
+ it('judges the fired check against the active harness, not a global installation of another', async () => {
3064
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-active-harness-'))
3065
+ const io = new CapturedIo()
3066
+ const env = {
3067
+ ...isolatedEnv(cwd),
3068
+ CLAUDECODE: '1',
3069
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3070
+ CODEX_THREAD_ID: 'codex-current-thread',
3071
+ }
3072
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3073
+
3074
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3075
+ expect(
3076
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3077
+ ).toBe(EXIT.ok)
3078
+ trustInstalledCodexHooks(cwd, env)
3079
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3080
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3081
+
3082
+ const readiness = await assessReadiness(deps)
3083
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3084
+ // The Codex hooks fired. A missing Claude Code pointer says nothing about
3085
+ // that, and telling this agent to send a Claude Code prompt is advice it
3086
+ // cannot act on: it would refuse to ask, forever.
3087
+ expect(fired?.status).toBe('ready')
3088
+ expect(fired?.detail).toMatch(/active Codex session/i)
3089
+ expect(fired?.detail).not.toMatch(/Claude Code/i)
3090
+ })
3091
+
3092
+ it('advises every harness whose markers are present when none of them has fired here', async () => {
3093
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-contested-'))
3094
+ const io = new CapturedIo()
3095
+ const env = {
3096
+ ...isolatedEnv(cwd),
3097
+ CLAUDECODE: '1',
3098
+ CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
3099
+ CODEX_THREAD_ID: 'codex-current-thread',
3100
+ }
3101
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3102
+
3103
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3104
+ expect(
3105
+ hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
3106
+ ).toBe(EXIT.ok)
3107
+ trustInstalledCodexHooks(cwd, env)
3108
+
3109
+ const readiness = await assessReadiness(deps)
3110
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3111
+ expect(fired?.status).toBe('optional-gap')
3112
+ expect(fired?.detail).toMatch(/Claude Code/)
3113
+ expect(fired?.detail).toMatch(/Codex: send one prompt/)
3114
+
3115
+ io.errLines = []
3116
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3117
+ expect(io.errLines.join(' ')).toMatch(/Several harness markers are present/i)
3118
+ })
3119
+
3120
+ it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
3121
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-commitment-'))
3122
+ const io = new CapturedIo()
3123
+ const env = {
3124
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3125
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3126
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3127
+ CODEX_THREAD_ID: 'codex-commitment-thread',
3128
+ }
3129
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3130
+
3131
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3132
+ trustInstalledCodexHooks(cwd, env)
3133
+ writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3134
+ writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
3135
+ io.outLines = []
3136
+
3137
+ expect(
3138
+ askCommand(deps, 'Which environment should I deploy to?', {
3139
+ choice: ['Staging', 'Production', 'Cancel'],
3140
+ }),
3141
+ ).toBe(EXIT.ok)
3142
+
3143
+ const said = io.outLines.join('\n')
3144
+ expect(said).toContain('Before ending this turn')
3145
+ for (const answer of ['Staging', 'Production', 'Cancel']) {
3146
+ expect(said).toContain(`If the answer is "${answer}"`)
3147
+ }
3148
+ expect(said).toContain('unexpected typed answer')
3149
+ expect(said).toContain('work you will resume')
3150
+ expect(said).toContain('without asking the user to confirm again')
3151
+ expect(said).toContain('not as approval')
3152
+ expect(said).toContain('cannot answer a harness permission prompt or interactive picker')
3153
+ })
3154
+
3155
+ it('requires a concrete fallback for free-text answers before the turn ends', () => {
3156
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-free-text-'))
3157
+ const io = new CapturedIo()
3158
+ const env = {
3159
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3160
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3161
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3162
+ CODEX_THREAD_ID: 'codex-free-text-thread',
3163
+ }
3164
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3165
+
3166
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3167
+ trustInstalledCodexHooks(cwd, env)
3168
+ writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3169
+ writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
3170
+ io.outLines = []
3171
+
3172
+ expect(askCommand(deps, 'What rollout adjustment should I make?', {})).toBe(EXIT.ok)
3173
+ expect(io.outLines.join('\n')).toContain(
3174
+ 'For the free-text answer: state how its content will determine the concrete work you resume.',
2258
3175
  )
2259
3176
  })
2260
3177
 
3178
+ it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
3179
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-stale-trust-'))
3180
+ const io = new CapturedIo()
3181
+ const env = {
3182
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3183
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3184
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3185
+ CODEX_THREAD_ID: 'codex-current-thread',
3186
+ }
3187
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3188
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3189
+ trustInstalledCodexHooks(cwd, env)
3190
+ const stop = findInstallations(cwd, env)
3191
+ .find((installation) => installation.harness === 'codex')
3192
+ ?.handlers.find((handler) => handler.event === 'Stop')
3193
+ expect(stop).toBeDefined()
3194
+ const configFile = path.join(env.CODEX_HOME, 'config.toml')
3195
+ writeFileSync(
3196
+ configFile,
3197
+ readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
3198
+ )
3199
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3200
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3201
+ io.outLines = []
3202
+
3203
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3204
+ expect(io.errLines.join('\n')).toMatch(/Stop.*changed since it was trusted.*\/hooks/is)
3205
+
3206
+ io.outLines = []
3207
+ await doctorCommand(deps, {})
3208
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
3209
+ expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
3210
+ })
3211
+
2261
3212
  it('gives doctor the same active-Codex diagnosis as ask', async () => {
2262
3213
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
2263
3214
  const io = new CapturedIo()
@@ -2286,7 +3237,7 @@ describe('asking before the hooks have ever run', () => {
2286
3237
  source: 'Raidiant-io/notifai',
2287
3238
  sourceType: 'github',
2288
3239
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2289
- ref: 'v0.3.0',
3240
+ ref: 'v0.5.0',
2290
3241
  },
2291
3242
  ]
2292
3243
  : [],
@@ -2323,7 +3274,7 @@ describe('asking before the hooks have ever run', () => {
2323
3274
  io.outLines = []
2324
3275
 
2325
3276
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2326
- expect(io.errLines.join(' ')).toMatch(/another Claude Code session/i)
3277
+ expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
2327
3278
  })
2328
3279
 
2329
3280
  it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
@@ -2341,7 +3292,7 @@ describe('asking before the hooks have ever run', () => {
2341
3292
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2342
3293
  EXIT.ok,
2343
3294
  )
2344
- writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
3295
+ writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
2345
3296
  writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
2346
3297
  io.outLines = []
2347
3298
 
@@ -2349,6 +3300,80 @@ describe('asking before the hooks have ever run', () => {
2349
3300
  expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
2350
3301
  })
2351
3302
 
3303
+ it('fails closed when Claude Code does not expose an exact session id', () => {
3304
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-no-id-'))
3305
+ const io = new CapturedIo()
3306
+ const env = {
3307
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3308
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3309
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
3310
+ CLAUDECODE: '1',
3311
+ }
3312
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3313
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3314
+ EXIT.ok,
3315
+ )
3316
+ writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
3317
+ writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
3318
+ io.outLines = []
3319
+
3320
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3321
+ expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
3322
+ expect(readSessionState('claude-last-writer', env).pending).toBeUndefined()
3323
+ })
3324
+
3325
+ it('rejects duplicate active Codex definitions before registration', () => {
3326
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-duplicate-'))
3327
+ const io = new CapturedIo()
3328
+ const env = {
3329
+ HOME: path.join(cwd, 'home'),
3330
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3331
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3332
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3333
+ CODEX_THREAD_ID: 'codex-current-thread',
3334
+ }
3335
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3336
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3337
+ expect(
3338
+ hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
3339
+ ).toBe(EXIT.ok)
3340
+ trustInstalledCodexHooks(cwd, env)
3341
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3342
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
3343
+ io.outLines = []
3344
+
3345
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3346
+ expect(io.errLines.join('\n')).toMatch(/2 Codex definitions are active/i)
3347
+ expect(readSessionState('codex-current-thread', env).pending).toBeUndefined()
3348
+ })
3349
+
3350
+ it('does not let UserPromptSubmit alone count as a working turn-end route', async () => {
3351
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-prompt-only-'))
3352
+ const io = new CapturedIo()
3353
+ const env = {
3354
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3355
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3356
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
3357
+ CLAUDECODE: '1',
3358
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
3359
+ }
3360
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3361
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
3362
+ EXIT.ok,
3363
+ )
3364
+ writeSessionState('claude-current', env, { last_prompt_at: 42 })
3365
+ writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
3366
+ io.outLines = []
3367
+
3368
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3369
+ expect(io.errLines.join('\n')).toMatch(/Stop hook has not been observed/)
3370
+
3371
+ const readiness = await assessReadiness(deps)
3372
+ const fired = readiness.states.find((state) => state.id === 'hooks-fired')
3373
+ expect(fired?.status).toBe('gap')
3374
+ expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
3375
+ })
3376
+
2352
3377
  it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
2353
3378
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
2354
3379
  const io = new CapturedIo()
@@ -2366,10 +3391,31 @@ describe('asking before the hooks have ever run', () => {
2366
3391
  io.outLines = []
2367
3392
 
2368
3393
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2369
- expect(io.errLines.join(' ')).toMatch(/another OpenCode session/i)
3394
+ expect(io.errLines.join(' ')).toMatch(/active OpenCode session:.*has not published/i)
3395
+ })
3396
+
3397
+ it('rejects OpenCode before registration even with an exact matching pointer', () => {
3398
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-unsupported-'))
3399
+ const io = new CapturedIo()
3400
+ const env = {
3401
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3402
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3403
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
3404
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
3405
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
3406
+ }
3407
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3408
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3409
+ writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
3410
+ writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
3411
+ io.outLines = []
3412
+
3413
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3414
+ expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
3415
+ expect(readSessionState('opencode-current', env).pending).toBeUndefined()
2370
3416
  })
2371
3417
 
2372
- it('recognizes Cursor only from its active-agent marker', () => {
3418
+ it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
2373
3419
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
2374
3420
  const io = new CapturedIo()
2375
3421
  const env = {
@@ -2379,12 +3425,13 @@ describe('asking before the hooks have ever run', () => {
2379
3425
  }
2380
3426
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2381
3427
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2382
- writeSessionState('cursor-live', env, { last_prompt_at: 42 })
3428
+ writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
2383
3429
  writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
2384
3430
  io.outLines = []
2385
3431
 
2386
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2387
- expect(readSessionState('cursor-live', env).pending?.[0]?.question).toBe('Ship it?')
3432
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
3433
+ expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
3434
+ expect(readSessionState('cursor-live', env).pending).toBeUndefined()
2388
3435
  })
2389
3436
 
2390
3437
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
@@ -2446,7 +3493,7 @@ describe('asking before the hooks have ever run', () => {
2446
3493
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
2447
3494
  writeFileSync(
2448
3495
  path.join(cwd, '.notifai', 'config.local.toml'),
2449
- 'ask_notifications = false\nrequire_idle = false\naway_after_seconds = 45\nask_grace_seconds = 90\nhook_reply_timeout_seconds = 120\n',
3496
+ 'ask_notifications = false\nask_grace_seconds = 90\n',
2450
3497
  )
2451
3498
  const io = new CapturedIo()
2452
3499
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
@@ -2456,27 +3503,167 @@ describe('asking before the hooks have ever run', () => {
2456
3503
  const said = io.outLines.join('\n')
2457
3504
  expect(said).toContain('Question routing settings:')
2458
3505
  expect(said).toContain('ask_notifications=false (project-local:')
2459
- expect(said).toContain('require_idle=false (project-local:')
2460
- expect(said).toContain('away_after_seconds=45 (project-local:')
2461
3506
  expect(said).toContain('ask_grace_seconds=90 (project-local:')
2462
- expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
3507
+ // Nothing about where the user is standing is listed, because nothing
3508
+ // about where they are standing decides anything any more.
3509
+ expect(said).not.toMatch(/require_idle|away_after_seconds|hook_reply_timeout_seconds/)
2463
3510
  })
2464
3511
 
2465
- it('fails doctor when runtime waits outgrow an installed Stop timeout', async () => {
3512
+ it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
2466
3513
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
2467
3514
  const io = new CapturedIo()
2468
- const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
2469
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3515
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2470
3516
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2471
3517
  EXIT.ok,
2472
3518
  )
2473
3519
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
2474
- writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
3520
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
2475
3521
  io.outLines = []
2476
3522
 
2477
- expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2478
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(timeout\)/)
2479
- expect(io.outLines.join('\n')).toContain('notifai hooks install --harness claude-code')
3523
+ await doctorCommand(deps, {})
3524
+ expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
3525
+ })
3526
+
3527
+ it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
3528
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
3529
+ const io = new CapturedIo()
3530
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
3531
+ // Exactly what every build before the asynchronous waiter wrote: a blocking
3532
+ // handler with the old ceiling. It looks installed and it fires, but the
3533
+ // waiter would hold the turn and then be killed at Claude's silent default.
3534
+ mkdirSync(path.join(cwd, '.claude'), { recursive: true })
3535
+ applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
3536
+ hooks: {
3537
+ Stop: [
3538
+ {
3539
+ hooks: [
3540
+ {
3541
+ type: 'command',
3542
+ command: `'${path.join(cwd, 'home', '.notifai', 'bin', 'hook-adapter')}' hook stop --owner notifai --harness claude-code`,
3543
+ timeout: 540,
3544
+ },
3545
+ ],
3546
+ },
3547
+ ],
3548
+ },
3549
+ })
3550
+
3551
+ await doctorCommand(deps, {})
3552
+ const out = io.outLines.join('\n')
3553
+ expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
3554
+ expect(out).toContain('needs `async: true`')
3555
+ expect(out).toContain('kills the backgrounded waiter at its 600s default')
3556
+ })
3557
+
3558
+ /** A configured Claude Code project with one live session claiming `pid`. */
3559
+ function claudeWakeDeps(
3560
+ io: CapturedIo,
3561
+ descriptor: (pid: number) => unknown,
3562
+ ): { deps: CommandDeps; cwd: string } {
3563
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-wake-'))
3564
+ const base = {
3565
+ ...makeDeps(io, {} as ApiClient),
3566
+ cwd,
3567
+ env: { ...isolatedEnv(cwd), CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'sess-live' },
3568
+ claudeSourcePid: 4242,
3569
+ claudeWake: { readDescriptor: descriptor } as unknown as ClaudeWakeAdapters,
3570
+ }
3571
+ expect(hooksInstallCommand(base, { harness: 'claude-code', execPath, scriptPath })).toBe(
3572
+ EXIT.ok,
3573
+ )
3574
+ io.outLines = []
3575
+ return { deps: base, cwd }
3576
+ }
3577
+
3578
+ it('reports a live Claude session as reachable without connecting to its socket', async () => {
3579
+ const io = new CapturedIo()
3580
+ const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
3581
+ writeFileSync(socket, '')
3582
+ const { deps } = claudeWakeDeps(io, (pid) => ({
3583
+ pid,
3584
+ sessionId: 'sess-live',
3585
+ cwd: '/tmp',
3586
+ startedAt: 1,
3587
+ procStart: 'x',
3588
+ version: '2.1.228',
3589
+ peerProtocol: 1,
3590
+ messagingSocketPath: socket,
3591
+ status: 'idle',
3592
+ }))
3593
+
3594
+ await doctorCommand(deps, {})
3595
+ const out = io.outLines.join('\n')
3596
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
3597
+ expect(out).toContain(socket)
3598
+ expect(out).toContain('an answer can start a turn here without you')
3599
+ // Doctor never widens the user's inbound policy to make Notifai work: the
3600
+ // poster is the session's own hook child and needs no such permission.
3601
+ expect(out).not.toContain('crossSessionInbound')
3602
+ })
3603
+
3604
+ it('names a --bare session as the reason an answer would wait for the next turn', async () => {
3605
+ const io = new CapturedIo()
3606
+ const { deps } = claudeWakeDeps(io, () => {
3607
+ throw new Error('ENOENT: no such file or directory')
3608
+ })
3609
+
3610
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
3611
+ const out = io.outLines.join('\n')
3612
+ // Honest, and not a blocker: nothing is lost, it simply arrives later.
3613
+ expect(out).toMatch(/--\s+hooks \(wake route\)/)
3614
+ expect(out).toContain('`--bare` binds no inbox socket')
3615
+ expect(out).toContain("at this session's next turn")
3616
+ expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
3617
+ })
3618
+
3619
+ it('fails closed on an inbox protocol it does not recognise', async () => {
3620
+ const io = new CapturedIo()
3621
+ const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
3622
+ writeFileSync(socket, '')
3623
+ const { deps } = claudeWakeDeps(io, (pid) => ({
3624
+ pid,
3625
+ sessionId: 'sess-live',
3626
+ cwd: '/tmp',
3627
+ startedAt: 1,
3628
+ procStart: 'x',
3629
+ version: '2.1.228',
3630
+ peerProtocol: 7,
3631
+ messagingSocketPath: socket,
3632
+ status: 'idle',
3633
+ }))
3634
+
3635
+ await doctorCommand(deps, {})
3636
+ const out = io.outLines.join('\n')
3637
+ expect(out).toContain('speaks inbox protocol 7')
3638
+ expect(out).toContain('refusing to guess at an undocumented wire format')
3639
+ })
3640
+
3641
+ it('reports Codex resume readiness from its writer-lock directory alone', async () => {
3642
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
3643
+ const io = new CapturedIo()
3644
+ const env = {
3645
+ ...isolatedEnv(cwd),
3646
+ CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
3647
+ }
3648
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3649
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3650
+
3651
+ io.outLines = []
3652
+ await doctorCommand(deps, {})
3653
+ expect(io.outLines.join('\n')).toContain('nothing can be resumed: no thread-writer-lock')
3654
+
3655
+ mkdirSync(path.join(env['CODEX_HOME']!, 'thread-writer-locks'), { recursive: true })
3656
+ io.outLines = []
3657
+ await doctorCommand(deps, {})
3658
+ const out = io.outLines.join('\n')
3659
+ if (process.platform === 'darwin') {
3660
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
3661
+ expect(out).toContain('can prove a stopped thread unowned before resuming it')
3662
+ } else {
3663
+ // Linux has no non-blocking `flock` through Node's `open(2)` flags, so
3664
+ // the gate stays shut and doctor says so rather than implying a wake.
3665
+ expect(out).toContain('nothing can be resumed')
3666
+ }
2480
3667
  })
2481
3668
 
2482
3669
  it('documents the failing exit contract in doctor JSON', async () => {
@@ -2494,10 +3681,7 @@ describe('asking before the hooks have ever run', () => {
2494
3681
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
2495
3682
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
2496
3683
  hooks: buildHookConfig({
2497
- execPath: '/usr/bin/node',
2498
- scriptPath: '/opt/notifai/main.js',
2499
- replyTimeoutSeconds: 180,
2500
- graceSeconds: 300,
3684
+ adapterPath: path.join(cwd, 'notifai', 'hook-adapter'),
2501
3685
  }),
2502
3686
  })
2503
3687
 
@@ -2526,7 +3710,7 @@ describe('asking before the hooks have ever run', () => {
2526
3710
  expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
2527
3711
  })
2528
3712
 
2529
- it('keeps OpenCode activation separate from answer continuation', () => {
3713
+ it('keeps OpenCode activation separate from unsupported answer continuation', () => {
2530
3714
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
2531
3715
  const io = new CapturedIo()
2532
3716
  const deps = {
@@ -2544,7 +3728,7 @@ describe('asking before the hooks have ever run', () => {
2544
3728
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2545
3729
  const said = io.errLines.join(' ')
2546
3730
  expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
2547
- expect(said).toContain('device answer is delivered on the next prompt')
3731
+ expect(said).toContain('non-blocking question continuation is intentionally unsupported')
2548
3732
  })
2549
3733
 
2550
3734
  it('says to install when nothing is installed at all', () => {