@raidiant/notifai 0.3.0 → 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 (80) 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 +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/client.test.ts +12 -0
  52. package/src/client.ts +72 -4
  53. package/src/commands.test.ts +891 -43
  54. package/src/commands.ts +1133 -332
  55. package/src/config-schema.ts +51 -9
  56. package/src/config.test.ts +6 -5
  57. package/src/config.ts +85 -16
  58. package/src/file-lock-process-worker.test.ts +64 -0
  59. package/src/file-lock.test.ts +279 -0
  60. package/src/file-lock.ts +318 -0
  61. package/src/harnesses.test.ts +48 -0
  62. package/src/harnesses.ts +59 -0
  63. package/src/hook-adapter.test.ts +187 -0
  64. package/src/hook-adapter.ts +212 -0
  65. package/src/hook-input.test.ts +39 -0
  66. package/src/hook-input.ts +72 -0
  67. package/src/hooks.test.ts +1318 -240
  68. package/src/hooks.ts +1046 -296
  69. package/src/install-hooks.test.ts +90 -41
  70. package/src/install-hooks.ts +259 -74
  71. package/src/interactive.ts +3 -3
  72. package/src/logging-process-worker.test.ts +39 -0
  73. package/src/logging.test.ts +453 -0
  74. package/src/logging.ts +639 -0
  75. package/src/main.ts +113 -32
  76. package/src/opencode-plugin.ts +15 -16
  77. package/src/session-end-log-lock-worker.test.ts +35 -0
  78. package/src/skill.test.ts +11 -0
  79. package/src/ui/config-view.test.ts +4 -2
  80. package/src/ui/config-view.ts +3 -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,
@@ -20,6 +21,7 @@ import {
20
21
  configExplainCommand,
21
22
  configSetCommand,
22
23
  configShowCommand,
24
+ configUnsetCommand,
23
25
  contradictingAnswer,
24
26
  describeHookFailure,
25
27
  doctorCommand,
@@ -29,6 +31,8 @@ import {
29
31
  initCommand,
30
32
  SKILLS_SOURCE,
31
33
  loginCommand,
34
+ logsCommand,
35
+ parseSince,
32
36
  projectSlugFrom,
33
37
  repliesCommand,
34
38
  sendCommand,
@@ -37,10 +41,18 @@ import {
37
41
  type CommandIo,
38
42
  type CommandSpinner,
39
43
  } from './commands.js'
40
- import { applyPlan, buildHookConfig } from './install-hooks.js'
44
+ import {
45
+ applyPlan,
46
+ buildHookConfig,
47
+ codexHookIdentityHash,
48
+ codexTrustKey,
49
+ findInstallations,
50
+ } from './install-hooks.js'
41
51
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
42
52
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
43
- import { CONFIG_KEYS } from './config.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'
44
56
  import type { Tone } from './ui/theme.js'
45
57
 
46
58
  class CapturedIo implements CommandIo {
@@ -120,7 +132,23 @@ class InteractiveIo extends CapturedIo {
120
132
  }
121
133
  }
122
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
+
123
150
  function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
151
+ const testRoot = path.join(os.tmpdir(), 'notifai-cli-command-tests')
124
152
  return {
125
153
  io,
126
154
  store: {
@@ -134,7 +162,11 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
134
162
  clear: () => {},
135
163
  describe: () => 'test credential store',
136
164
  },
137
- 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'),
138
170
  cwd: os.tmpdir(),
139
171
  clientFactory: () => client,
140
172
  }
@@ -494,13 +526,19 @@ describe('command contracts', () => {
494
526
  return replyResponse(polls.length === 3 ? [reply] : [])
495
527
  },
496
528
  } as unknown as ApiClient
529
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-direct-reply-log-'))
497
530
  const deps = {
498
531
  ...makeDeps(io, client),
532
+ env: {
533
+ XDG_CONFIG_HOME: path.join(root, 'config'),
534
+ XDG_STATE_HOME: path.join(root, 'state'),
535
+ },
499
536
  now: () => now,
500
537
  sleep: async (milliseconds: number) => {
501
538
  now += milliseconds
502
539
  },
503
540
  }
541
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
504
542
 
505
543
  expect(
506
544
  await sendCommand(deps, {
@@ -513,6 +551,14 @@ describe('command contracts', () => {
513
551
  expect(polls).toHaveLength(3)
514
552
  expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
515
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
+ })
516
562
  })
517
563
 
518
564
  it('backs off and retries a transient network error while waiting', async () => {
@@ -549,6 +595,97 @@ describe('command contracts', () => {
549
595
  expect(sleeps).toEqual([250])
550
596
  })
551
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
+
552
689
  it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
553
690
  const io = new CapturedIo()
554
691
  let now = 0
@@ -619,10 +756,18 @@ describe('command contracts', () => {
619
756
  return replyResponse([reply])
620
757
  },
621
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' })
622
766
 
623
- 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)
624
768
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
625
769
  expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
770
+ expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
626
771
  })
627
772
 
628
773
  it('resolves replies --pending through the project session pointer and prints its id', async () => {
@@ -678,6 +823,198 @@ describe('command contracts', () => {
678
823
  expect(io.outLines[1]).toContain('yes, after the migration')
679
824
  expect(io.outLines.join('\n')).toContain('req_second')
680
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
+ })
681
1018
  })
682
1019
 
683
1020
  describe('delivery evidence status', () => {
@@ -734,6 +1071,8 @@ describe('delivery evidence status', () => {
734
1071
  expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
735
1072
  expect(said).toContain('not a failure')
736
1073
  expect(said).toContain('attempt_started')
1074
+ expect(said).toContain('OS presentation: not observed by Notifai')
1075
+ expect(said).toContain('Reply wait:')
737
1076
  })
738
1077
 
739
1078
  it('reports the observed device receipt and measured provider-to-companion latency', async () => {
@@ -751,12 +1090,40 @@ describe('delivery evidence status', () => {
751
1090
  const said = io.outLines.join('\n')
752
1091
  expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
753
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')
754
1121
  })
755
1122
  })
756
1123
 
757
1124
  describe('Cursor hook commands', () => {
758
- const execPath = '/usr/local/bin/node'
759
- const scriptPath = '/opt/notifai/dist/main.js'
1125
+ const execPath = process.execPath
1126
+ const scriptPath = fileURLToPath(import.meta.url)
760
1127
 
761
1128
  it('installs native Cursor hooks with bounded chained answer continuations', () => {
762
1129
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
@@ -850,8 +1217,8 @@ describe('Cursor hook commands', () => {
850
1217
  })
851
1218
 
852
1219
  describe('harness activation guidance', () => {
853
- const execPath = '/usr/local/bin/node'
854
- const scriptPath = '/opt/notifai/dist/main.js'
1220
+ const execPath = process.execPath
1221
+ const scriptPath = fileURLToPath(import.meta.url)
855
1222
 
856
1223
  it('does not require a Claude Code restart for project hook files', () => {
857
1224
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
@@ -881,7 +1248,7 @@ describe('harness activation guidance', () => {
881
1248
  expect(output).not.toMatch(/trust|approve/i)
882
1249
  })
883
1250
 
884
- it('keeps OpenCode permission prompts local and reports its continuation limit', async () => {
1251
+ it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
885
1252
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
886
1253
  const io = new CapturedIo()
887
1254
  const client = {
@@ -889,21 +1256,33 @@ describe('harness activation guidance', () => {
889
1256
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
890
1257
  listDevices: async () => ({ devices: [] }),
891
1258
  } as unknown as ApiClient
892
- 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
+ }
893
1271
 
894
1272
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
895
1273
  EXIT.ok,
896
1274
  )
897
1275
 
898
1276
  expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
899
- expect(io.outLines.join('\n')).toContain('next user prompt')
1277
+ expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
900
1278
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
901
1279
  const plugin = readFileSync(pluginFile, 'utf8')
902
1280
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
903
1281
 
904
1282
  io.outLines = []
905
1283
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
906
- 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')
907
1286
  expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
908
1287
 
909
1288
  writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
@@ -912,6 +1291,107 @@ describe('harness activation guidance', () => {
912
1291
  expect(io.outLines.join('\n')).toContain('hooks (adapter)')
913
1292
  expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
914
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
+ })
915
1395
  })
916
1396
 
917
1397
  describe('projectSlugFrom', () => {
@@ -923,6 +1403,156 @@ describe('projectSlugFrom', () => {
923
1403
  })
924
1404
  })
925
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
+
926
1556
  describe('config surfaces', () => {
927
1557
  function configDeps(io: CapturedIo): CommandDeps {
928
1558
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
@@ -959,7 +1589,8 @@ describe('config surfaces', () => {
959
1589
  const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
960
1590
  expect(parsed['key']).toBe('require_idle')
961
1591
  expect(parsed['accepts']).toBe('true or false')
962
- expect(parsed['detail']).toContain('question waits while you are using this machine')
1592
+ expect(parsed['detail']).toContain('Off (the default)')
1593
+ expect(parsed['detail']).toContain('Turn this on')
963
1594
  })
964
1595
 
965
1596
  it('rejects an unknown setting and points at the nearest real one', () => {
@@ -982,6 +1613,49 @@ describe('config surfaces', () => {
982
1613
  const io = new CapturedIo()
983
1614
  expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
984
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
+ })
985
1659
  })
986
1660
 
987
1661
  describe('interactive command UX', () => {
@@ -1099,6 +1773,25 @@ describe('interactive command UX', () => {
1099
1773
  )
1100
1774
  })
1101
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
+
1102
1795
  it('rejects numeric config values that resolution would otherwise silently clamp', async () => {
1103
1796
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-bounds-'))
1104
1797
  const io = new CapturedIo()
@@ -1116,10 +1809,10 @@ describe('interactive command UX', () => {
1116
1809
  EXIT.usage,
1117
1810
  )
1118
1811
  expect(io.errLines).toEqual([
1119
- 'ask_grace_seconds must be between 0 and 540.',
1812
+ 'ask_grace_seconds must be between 0 and 334.',
1120
1813
  // Names the key and its range: `"1.5" is not an integer` left the reader
1121
1814
  // 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".',
1815
+ 'ask_grace_seconds takes a whole number from 0s–334s, not "1.5".',
1123
1816
  ])
1124
1817
  expect(existsSync(configFile)).toBe(false)
1125
1818
  })
@@ -1260,7 +1953,7 @@ describe('init', () => {
1260
1953
  source: 'Raidiant-io/notifai',
1261
1954
  sourceType: 'github',
1262
1955
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1263
- ref: 'v0.3.0',
1956
+ ref: 'v0.4.0',
1264
1957
  }
1265
1958
  }
1266
1959
 
@@ -1363,7 +2056,7 @@ describe('init', () => {
1363
2056
  })
1364
2057
 
1365
2058
  it('pins the skill installer to the tagged public release syntax', () => {
1366
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.3.0')
2059
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.4.0')
1367
2060
  expect(SKILLS_SOURCE).not.toContain('@v')
1368
2061
  })
1369
2062
 
@@ -1386,7 +2079,7 @@ describe('init', () => {
1386
2079
  source: 'Raidiant-io/notifai',
1387
2080
  sourceType: 'github',
1388
2081
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1389
- ref: 'v0.3.0',
2082
+ ref: 'v0.4.0',
1390
2083
  },
1391
2084
  ],
1392
2085
  }),
@@ -2114,7 +2807,9 @@ describe('an outage is not an answer', () => {
2114
2807
  * agent scripted to read exit 3 as "nobody objected" would proceed against a
2115
2808
  * refusal it never saw.
2116
2809
  */
2117
- function outageAfterFirstPoll(io: CapturedIo): CommandDeps {
2810
+ function outageAfterFirstPoll(io: CapturedIo, fault: () => never = () => {
2811
+ throw new NetworkError('link went down')
2812
+ }): CommandDeps {
2118
2813
  let now = 0
2119
2814
  let polls = 0
2120
2815
  const client = {
@@ -2122,7 +2817,7 @@ describe('an outage is not an answer', () => {
2122
2817
  replies: async () => {
2123
2818
  polls += 1
2124
2819
  if (polls === 1) return replyResponse([])
2125
- throw new NetworkError('link went down')
2820
+ fault()
2126
2821
  },
2127
2822
  } as unknown as ApiClient
2128
2823
  return {
@@ -2148,6 +2843,27 @@ describe('an outage is not an answer', () => {
2148
2843
  expect(exit).not.toBe(EXIT.noReply)
2149
2844
  expect(exit).toBe(EXIT.network)
2150
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')
2151
2867
  })
2152
2868
 
2153
2869
  it('marks the JSON so an agent reading it programmatically can tell', async () => {
@@ -2186,8 +2902,8 @@ describe('an outage is not an answer', () => {
2186
2902
  })
2187
2903
 
2188
2904
  describe('asking before the hooks have ever run', () => {
2189
- const execPath = '/usr/local/bin/node'
2190
- const scriptPath = '/opt/notifai/dist/main.js'
2905
+ const execPath = process.execPath
2906
+ const scriptPath = fileURLToPath(import.meta.url)
2191
2907
 
2192
2908
  it('names the active Codex harness instead of unrelated installed adapters', () => {
2193
2909
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
@@ -2230,7 +2946,7 @@ describe('asking before the hooks have ever run', () => {
2230
2946
  io.outLines = []
2231
2947
 
2232
2948
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2233
- expect(io.errLines.join(' ')).toMatch(/belongs to another Codex session/i)
2949
+ expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
2234
2950
  expect(io.outLines).not.toContain(
2235
2951
  'Question registered. Ask it in the conversation as usual and end your turn.',
2236
2952
  )
@@ -2248,8 +2964,11 @@ describe('asking before the hooks have ever run', () => {
2248
2964
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2249
2965
 
2250
2966
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2251
- 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 })
2252
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')
2253
2972
  io.outLines = []
2254
2973
 
2255
2974
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
@@ -2258,6 +2977,40 @@ describe('asking before the hooks have ever run', () => {
2258
2977
  )
2259
2978
  })
2260
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
+
2261
3014
  it('gives doctor the same active-Codex diagnosis as ask', async () => {
2262
3015
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
2263
3016
  const io = new CapturedIo()
@@ -2286,7 +3039,7 @@ describe('asking before the hooks have ever run', () => {
2286
3039
  source: 'Raidiant-io/notifai',
2287
3040
  sourceType: 'github',
2288
3041
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2289
- ref: 'v0.3.0',
3042
+ ref: 'v0.4.0',
2290
3043
  },
2291
3044
  ]
2292
3045
  : [],
@@ -2323,7 +3076,7 @@ describe('asking before the hooks have ever run', () => {
2323
3076
  io.outLines = []
2324
3077
 
2325
3078
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2326
- expect(io.errLines.join(' ')).toMatch(/another Claude Code session/i)
3079
+ expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
2327
3080
  })
2328
3081
 
2329
3082
  it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
@@ -2341,7 +3094,7 @@ describe('asking before the hooks have ever run', () => {
2341
3094
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2342
3095
  EXIT.ok,
2343
3096
  )
2344
- writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
3097
+ writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
2345
3098
  writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
2346
3099
  io.outLines = []
2347
3100
 
@@ -2349,6 +3102,80 @@ describe('asking before the hooks have ever run', () => {
2349
3102
  expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
2350
3103
  })
2351
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
+
2352
3179
  it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
2353
3180
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
2354
3181
  const io = new CapturedIo()
@@ -2366,10 +3193,31 @@ describe('asking before the hooks have ever run', () => {
2366
3193
  io.outLines = []
2367
3194
 
2368
3195
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2369
- 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()
2370
3218
  })
2371
3219
 
2372
- it('recognizes Cursor only from its active-agent marker', () => {
3220
+ it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
2373
3221
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
2374
3222
  const io = new CapturedIo()
2375
3223
  const env = {
@@ -2379,12 +3227,13 @@ describe('asking before the hooks have ever run', () => {
2379
3227
  }
2380
3228
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2381
3229
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2382
- writeSessionState('cursor-live', env, { last_prompt_at: 42 })
3230
+ writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
2383
3231
  writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
2384
3232
  io.outLines = []
2385
3233
 
2386
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2387
- 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()
2388
3237
  })
2389
3238
 
2390
3239
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
@@ -2462,7 +3311,7 @@ describe('asking before the hooks have ever run', () => {
2462
3311
  expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
2463
3312
  })
2464
3313
 
2465
- 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 () => {
2466
3315
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
2467
3316
  const io = new CapturedIo()
2468
3317
  const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
@@ -2474,9 +3323,11 @@ describe('asking before the hooks have ever run', () => {
2474
3323
  writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
2475
3324
  io.outLines = []
2476
3325
 
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')
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
+ )
2480
3331
  })
2481
3332
 
2482
3333
  it('documents the failing exit contract in doctor JSON', async () => {
@@ -2494,10 +3345,7 @@ describe('asking before the hooks have ever run', () => {
2494
3345
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
2495
3346
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
2496
3347
  hooks: buildHookConfig({
2497
- execPath: '/usr/bin/node',
2498
- scriptPath: '/opt/notifai/main.js',
2499
- replyTimeoutSeconds: 180,
2500
- graceSeconds: 300,
3348
+ adapterPath: path.join(cwd, 'notifai', 'hook-adapter'),
2501
3349
  }),
2502
3350
  })
2503
3351
 
@@ -2526,7 +3374,7 @@ describe('asking before the hooks have ever run', () => {
2526
3374
  expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
2527
3375
  })
2528
3376
 
2529
- it('keeps OpenCode activation separate from answer continuation', () => {
3377
+ it('keeps OpenCode activation separate from unsupported answer continuation', () => {
2530
3378
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
2531
3379
  const io = new CapturedIo()
2532
3380
  const deps = {
@@ -2544,7 +3392,7 @@ describe('asking before the hooks have ever run', () => {
2544
3392
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2545
3393
  const said = io.errLines.join(' ')
2546
3394
  expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
2547
- expect(said).toContain('device answer is delivered on the next prompt')
3395
+ expect(said).toContain('non-blocking question continuation is intentionally unsupported')
2548
3396
  })
2549
3397
 
2550
3398
  it('says to install when nothing is installed at all', () => {