@raidiant/notifai 1.0.1 → 2.0.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 (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +17 -7
  3. package/dist/client.d.ts +5 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands.d.ts +10 -1
  7. package/dist/commands.js +151 -54
  8. package/dist/commands.js.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.js +5 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/credentials.d.ts +54 -3
  13. package/dist/credentials.js +237 -18
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/file-lock.js +15 -6
  16. package/dist/file-lock.js.map +1 -1
  17. package/dist/hook-adapter.d.ts +6 -2
  18. package/dist/hook-adapter.js +110 -29
  19. package/dist/hook-adapter.js.map +1 -1
  20. package/dist/hooks.d.ts +15 -1
  21. package/dist/hooks.js +183 -12
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +50 -21
  24. package/dist/install-hooks.js +141 -57
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/logging.d.ts +1 -1
  27. package/dist/logging.js +4 -0
  28. package/dist/logging.js.map +1 -1
  29. package/dist/main.js +14 -3
  30. package/dist/main.js.map +1 -1
  31. package/dist/native-skills.js +5 -10
  32. package/dist/native-skills.js.map +1 -1
  33. package/dist/opencode-plugin.d.ts +7 -2
  34. package/dist/opencode-plugin.js +142 -4
  35. package/dist/opencode-plugin.js.map +1 -1
  36. package/dist/platform.d.ts +73 -0
  37. package/dist/platform.js +143 -0
  38. package/dist/platform.js.map +1 -0
  39. package/dist/ui/config-view.js +5 -1
  40. package/dist/ui/config-view.js.map +1 -1
  41. package/package.json +11 -10
  42. package/src/client.test.ts +53 -0
  43. package/src/client.ts +24 -0
  44. package/src/commands.test.ts +351 -30
  45. package/src/commands.ts +207 -49
  46. package/src/config.ts +11 -9
  47. package/src/credentials.test.ts +291 -0
  48. package/src/credentials.ts +274 -17
  49. package/src/file-lock.ts +34 -9
  50. package/src/hook-adapter.test.ts +136 -0
  51. package/src/hook-adapter.ts +120 -28
  52. package/src/hooks.test.ts +175 -1
  53. package/src/hooks.ts +241 -12
  54. package/src/install-hooks.test.ts +174 -33
  55. package/src/install-hooks.ts +186 -54
  56. package/src/logging.ts +4 -0
  57. package/src/main.ts +17 -2
  58. package/src/native-skills.ts +5 -10
  59. package/src/opencode-plugin.ts +148 -3
  60. package/src/platform.test.ts +152 -0
  61. package/src/platform.ts +173 -0
  62. package/src/skill.test.ts +32 -1
  63. package/src/ui/config-view.ts +5 -5
@@ -1,4 +1,13 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
2
11
  import os from 'node:os'
3
12
  import path from 'node:path'
4
13
  import { fileURLToPath } from 'node:url'
@@ -10,15 +19,17 @@ import type {
10
19
  SubmissionReceipt,
11
20
  SubmitNotificationRequestT,
12
21
  } from '@raidiant/notifai-protocol'
13
- import { describe, expect, it } from 'vitest'
22
+ import { afterEach, describe, expect, it } from 'vitest'
14
23
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
15
24
  import type { ClaudeWakeAdapters } from './claude-wake.js'
16
25
  import {
26
+ acknowledgeCommand,
17
27
  askCommand,
18
28
  accessStatusCommand,
19
29
  buildQuestions,
20
30
  assessReadiness,
21
31
  capabilitiesCommand,
32
+ closeCommand,
22
33
  configExplainCommand,
23
34
  configSetCommand,
24
35
  configShowCommand,
@@ -51,7 +62,7 @@ import {
51
62
  } from './install-hooks.js'
52
63
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
53
64
  import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
54
- import { CONFIG_KEYS, loadConfig, personalProjectConfigPath } from './config.js'
65
+ import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
55
66
  import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
56
67
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
57
68
  import type { Tone } from './ui/theme.js'
@@ -161,6 +172,29 @@ class InteractiveIo extends CapturedIo {
161
172
  }
162
173
 
163
174
  function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
175
+ const home = env['HOME']
176
+ if (home === undefined || home === '') {
177
+ throw new Error(
178
+ 'trustInstalledCodexHooks requires env.HOME; refusing to use the real home directory',
179
+ )
180
+ }
181
+ const resolvedHome = path.resolve(home)
182
+ const tmpRoot = path.resolve(os.tmpdir())
183
+ const underTmp = resolvedHome === tmpRoot || resolvedHome.startsWith(`${tmpRoot}${path.sep}`)
184
+ if (!underTmp) {
185
+ throw new Error(
186
+ `trustInstalledCodexHooks will only write under ${tmpRoot}, not ${resolvedHome}`,
187
+ )
188
+ }
189
+ const codexHome = env['CODEX_HOME'] !== undefined && env['CODEX_HOME'] !== ''
190
+ ? path.resolve(env['CODEX_HOME'])
191
+ : path.join(resolvedHome, '.codex')
192
+ if (codexHome !== tmpRoot && !codexHome.startsWith(`${tmpRoot}${path.sep}`)) {
193
+ throw new Error(
194
+ `trustInstalledCodexHooks will only write under ${tmpRoot}, not ${codexHome}`,
195
+ )
196
+ }
197
+ const file = path.join(codexHome, 'config.toml')
164
198
  const installations = findInstallations(cwd, env).filter(
165
199
  (installation) => installation.harness === 'codex',
166
200
  )
@@ -170,9 +204,7 @@ function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
170
204
  return `[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(codexHookIdentityHash(handler))}\n`
171
205
  }),
172
206
  )
173
- const codexHome = env['CODEX_HOME'] as string
174
- mkdirSync(codexHome, { recursive: true })
175
- const file = path.join(codexHome, 'config.toml')
207
+ mkdirSync(path.dirname(file), { recursive: true })
176
208
  // Trust lives in the same config.toml as inline [hooks]. Overwriting the
177
209
  // file would delete the global handlers this helper is trying to trust.
178
210
  const existing = existsSync(file) ? readFileSync(file, 'utf8').trimEnd() : ''
@@ -219,6 +251,7 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
219
251
  HOME: home,
220
252
  CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
221
253
  CODEX_HOME: path.join(home, '.codex'),
254
+ OPENCODE_CONFIG_DIR: path.join(home, '.config', 'opencode'),
222
255
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
223
256
  XDG_STATE_HOME: path.join(cwd, 'state'),
224
257
  }
@@ -226,6 +259,8 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
226
259
 
227
260
  const receipt: SubmissionReceipt = {
228
261
  request_id: 'req_reply_test',
262
+ reply_expires_at: '2026-08-02T18:00:00.000Z',
263
+ agent_acknowledgement_required: true,
229
264
  replayed: false,
230
265
  overall: 'provider_accepted_all',
231
266
  deliveries: [
@@ -254,10 +289,18 @@ const reply: ReplyView = {
254
289
  created_at: '2026-08-01T18:01:00.000Z',
255
290
  }
256
291
 
257
- function replyResponse(replies: ReplyView[] = []): ListRepliesResponse {
292
+ function replyResponse(
293
+ replies: ReplyView[] = [],
294
+ options: {
295
+ required?: boolean
296
+ acknowledgement?: ListRepliesResponse['agent_acknowledgement']
297
+ } = {},
298
+ ): ListRepliesResponse {
258
299
  return {
259
300
  request_id: receipt.request_id,
260
301
  reply_expires_at: '2026-08-02T18:00:00.000Z',
302
+ agent_acknowledgement_required: options.required ?? true,
303
+ agent_acknowledgement: options.acknowledgement ?? null,
261
304
  replies,
262
305
  }
263
306
  }
@@ -604,7 +647,11 @@ describe('command contracts', () => {
604
647
  ).toBe(EXIT.ok)
605
648
  expect(polls).toHaveLength(3)
606
649
  expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
607
- expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
650
+ expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
651
+ expect(io.outLines).toContain('Agent Acknowledgement required.')
652
+ expect(io.outLines.join('\n')).toContain(
653
+ `notifai acknowledge ${receipt.request_id} --text <text>`,
654
+ )
608
655
  const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
609
656
  expect(received).toHaveLength(1)
610
657
  expect(received[0]?.data).toMatchObject({
@@ -687,7 +734,11 @@ describe('command contracts', () => {
687
734
  }),
688
735
  ).toBe(EXIT.ok)
689
736
  expect(replyCalls).toBe(3)
690
- expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
737
+ expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
738
+ expect(io.outLines).toContain('Agent Acknowledgement required.')
739
+ expect(io.outLines.join('\n')).toContain(
740
+ `notifai acknowledge ${receipt.request_id} --text <text>`,
741
+ )
691
742
  expect(io.errLines.join('\n')).not.toContain('internal_error')
692
743
  })
693
744
 
@@ -768,9 +819,14 @@ describe('command contracts', () => {
768
819
  expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
769
820
  type: 'reply_result',
770
821
  request_id: receipt.request_id,
822
+ reply_expires_at: '2026-08-02T18:00:00.000Z',
771
823
  replies: [],
824
+ agent_acknowledgement_required: true,
825
+ agent_acknowledgement: null,
826
+ acknowledgement_command: null,
772
827
  degraded: false,
773
828
  })
829
+ expect(io.outLines).not.toContain('Agent Acknowledgement required.')
774
830
  expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
775
831
  expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
776
832
  })
@@ -796,11 +852,227 @@ describe('command contracts', () => {
796
852
  expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
797
853
  type: 'reply_result',
798
854
  request_id: receipt.request_id,
855
+ reply_expires_at: '2026-08-02T18:00:00.000Z',
799
856
  replies: [reply],
857
+ agent_acknowledgement_required: true,
858
+ agent_acknowledgement: null,
859
+ acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
800
860
  degraded: false,
801
861
  })
802
862
  })
803
863
 
864
+ it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
865
+ const io = new CapturedIo()
866
+ let submitted: { requestId: string; text: string } | undefined
867
+ const client = {
868
+ putAgentAcknowledgement: async (requestId: string, body: { text: string }) => {
869
+ submitted = { requestId, text: body.text }
870
+ return {
871
+ status: 'recorded' as const,
872
+ agent_acknowledgement: {
873
+ text: body.text,
874
+ created_at: '2026-08-13T12:01:00.000Z',
875
+ },
876
+ }
877
+ },
878
+ } as unknown as ApiClient
879
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-acknowledge-log-'))
880
+ const deps = makeDeps(io, client)
881
+ deps.env = {
882
+ XDG_CONFIG_HOME: path.join(root, 'config'),
883
+ XDG_STATE_HOME: path.join(root, 'state'),
884
+ }
885
+ deps.logger = createLogger({ env: deps.env, cmd: 'acknowledge' })
886
+
887
+ expect(
888
+ await acknowledgeCommand(deps, receipt.request_id, {
889
+ text: ' I will deploy staging now. ',
890
+ json: true,
891
+ }),
892
+ ).toBe(EXIT.ok)
893
+ expect(submitted).toEqual({
894
+ requestId: receipt.request_id,
895
+ text: 'I will deploy staging now.',
896
+ })
897
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
898
+ request_id: receipt.request_id,
899
+ outcome: 'recorded',
900
+ acknowledgement: {
901
+ text: 'I will deploy staging now.',
902
+ created_at: '2026-08-13T12:01:00.000Z',
903
+ },
904
+ agent_acknowledgement_required: true,
905
+ })
906
+ const events = readLogRecords(deps.env, {
907
+ request: receipt.request_id,
908
+ event: ['acknowledgement.attempted', 'acknowledgement.outcome'],
909
+ }).records
910
+ expect(events.map((event) => event.event)).toEqual([
911
+ 'acknowledgement.attempted',
912
+ 'acknowledgement.outcome',
913
+ ])
914
+ })
915
+
916
+ it('clears a matching active-session obligation after acknowledgement success', async () => {
917
+ const io = new CapturedIo()
918
+ const client = {
919
+ putAgentAcknowledgement: async () => ({
920
+ status: 'recorded' as const,
921
+ agent_acknowledgement: {
922
+ text: 'I will deploy staging now.',
923
+ created_at: '2026-08-13T12:01:00.000Z',
924
+ },
925
+ }),
926
+ } as unknown as ApiClient
927
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-clear-'))
928
+ const deps = makeDeps(io, client)
929
+ deps.env = {
930
+ XDG_CONFIG_HOME: path.join(root, 'config'),
931
+ XDG_STATE_HOME: path.join(root, 'state'),
932
+ }
933
+ const now = 1_800_000_000_000
934
+ deps.now = () => now
935
+ writeSessionState('ack-session', deps.env, {
936
+ acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
937
+ })
938
+ writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
939
+
940
+ expect(
941
+ await acknowledgeCommand(deps, receipt.request_id, {
942
+ text: 'I will deploy staging now.',
943
+ }),
944
+ ).toBe(EXIT.ok)
945
+ expect(readSessionState('ack-session', deps.env).acknowledgement_due).toBeUndefined()
946
+ })
947
+
948
+ it('reports idempotent acknowledgement replay in concise human output', async () => {
949
+ const io = new CapturedIo()
950
+ const client = {
951
+ putAgentAcknowledgement: async () => ({
952
+ status: 'replayed' as const,
953
+ agent_acknowledgement: {
954
+ text: 'I will deploy staging now.',
955
+ created_at: '2026-08-13T12:01:00.000Z',
956
+ },
957
+ }),
958
+ } as unknown as ApiClient
959
+
960
+ expect(
961
+ await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {
962
+ text: 'I will deploy staging now.',
963
+ }),
964
+ ).toBe(EXIT.ok)
965
+ expect(io.outLines).toEqual([
966
+ `Agent Acknowledgement replayed for ${receipt.request_id} at 2026-08-13T12:01:00.000Z.`,
967
+ ])
968
+ })
969
+
970
+ it.each([
971
+ { text: undefined, message: '--text is required' },
972
+ { text: ' ', message: '--text is required' },
973
+ { text: 'x'.repeat(1001), message: 'at most 1000 characters' },
974
+ ])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
975
+ const io = new CapturedIo()
976
+ let calls = 0
977
+ const client = {
978
+ putAgentAcknowledgement: async () => {
979
+ calls += 1
980
+ throw new Error('should not be reached')
981
+ },
982
+ } as unknown as ApiClient
983
+
984
+ expect(
985
+ await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text }),
986
+ ).toBe(EXIT.usage)
987
+ expect(calls).toBe(0)
988
+ expect(io.errLines.join('\n')).toContain(message)
989
+ })
990
+
991
+ it.each([
992
+ [new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
993
+ [new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
994
+ [new ApiCallError(409, 'conflict', 'No user reply has been recorded yet.'), EXIT.failed, 'No user reply'],
995
+ [new ApiCallError(404, 'not_found', 'This request is unavailable or its content was purged.'), EXIT.failed, 'purged'],
996
+ [new ApiCallError(401, 'machine_revoked', 'This machine was revoked.'), EXIT.auth, 'machine_revoked'],
997
+ [new NetworkError('Could not reach the service'), EXIT.network, 'Could not reach'],
998
+ ] as const)(
999
+ 'maps stable acknowledgement failure %#',
1000
+ async (error, expectedExit, expectedCopy) => {
1001
+ const io = new CapturedIo()
1002
+ const client = {
1003
+ putAgentAcknowledgement: async () => {
1004
+ throw error
1005
+ },
1006
+ } as unknown as ApiClient
1007
+
1008
+ expect(
1009
+ await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text: 'Next work.' }),
1010
+ ).toBe(expectedExit)
1011
+ expect(io.errLines.join('\n')).toContain(expectedCopy)
1012
+ },
1013
+ )
1014
+
1015
+ it('reports required-but-not-yet-due state before any user reply', async () => {
1016
+ const io = new CapturedIo()
1017
+ const client = {
1018
+ replies: async () => replyResponse([]),
1019
+ } as unknown as ApiClient
1020
+
1021
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.noReply)
1022
+ expect(io.outLines).toContain(
1023
+ 'Agent Acknowledgement: required after a user reply; no reply is recorded yet.',
1024
+ )
1025
+ expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
1026
+ })
1027
+
1028
+ it('exposes disabled acknowledgement state without a follow-up command', async () => {
1029
+ const io = new CapturedIo()
1030
+ const client = {
1031
+ replies: async () => replyResponse([reply], { required: false }),
1032
+ } as unknown as ApiClient
1033
+
1034
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1035
+ expect(io.outLines).toContain('Agent Acknowledgement: not required for this request.')
1036
+ expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
1037
+ })
1038
+
1039
+ it('exposes an existing acknowledgement without repeating the follow-up command', async () => {
1040
+ const io = new CapturedIo()
1041
+ const acknowledgement = {
1042
+ text: 'I will deploy staging now.',
1043
+ created_at: '2026-08-13T12:01:00.000Z',
1044
+ }
1045
+ const client = {
1046
+ replies: async () => replyResponse([reply], { acknowledgement }),
1047
+ } as unknown as ApiClient
1048
+
1049
+ expect(
1050
+ await repliesCommand(makeDeps(io, client), receipt.request_id, { json: true }),
1051
+ ).toBe(EXIT.ok)
1052
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1053
+ agent_acknowledgement_required: true,
1054
+ agent_acknowledgement: acknowledgement,
1055
+ acknowledgement_command: null,
1056
+ })
1057
+ })
1058
+
1059
+ it('close exposes the acknowledgement requirement and exact follow-up command', async () => {
1060
+ const io = new CapturedIo()
1061
+ const client = {
1062
+ closeReplies: async () => replyResponse([reply]),
1063
+ } as unknown as ApiClient
1064
+
1065
+ expect(await closeCommand(makeDeps(io, client), receipt.request_id, { json: true })).toBe(
1066
+ EXIT.ok,
1067
+ )
1068
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1069
+ request_id: receipt.request_id,
1070
+ agent_acknowledgement_required: true,
1071
+ agent_acknowledgement: null,
1072
+ acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
1073
+ })
1074
+ })
1075
+
804
1076
  it('passes the replies cursor and prints replies for later retrieval', async () => {
805
1077
  const io = new CapturedIo()
806
1078
  let requested: { waitSeconds: number; afterSeq: number } | undefined
@@ -820,7 +1092,11 @@ describe('command contracts', () => {
820
1092
 
821
1093
  expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
822
1094
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
823
- expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
1095
+ expect(io.outLines).toEqual([
1096
+ 'reply from iPhone: yes, after the migration',
1097
+ 'Agent Acknowledgement required.',
1098
+ `next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
1099
+ ])
824
1100
  expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
825
1101
  })
826
1102
 
@@ -845,6 +1121,8 @@ describe('command contracts', () => {
845
1121
  expect(io.outLines).toEqual([
846
1122
  `pending request ${receipt.request_id}`,
847
1123
  'reply from iPhone: yes, after the migration',
1124
+ 'Agent Acknowledgement required.',
1125
+ `next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
848
1126
  ])
849
1127
  })
850
1128
 
@@ -1225,6 +1503,7 @@ describe('Cursor hook commands', () => {
1225
1503
  XDG_STATE_HOME: path.join(cwd, 'state'),
1226
1504
  CODEX_HOME: path.join(cwd, 'codex'),
1227
1505
  CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
1506
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode'),
1228
1507
  },
1229
1508
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
1230
1509
  }
@@ -1303,7 +1582,7 @@ describe('Codex hook representation', () => {
1303
1582
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
1304
1583
  })
1305
1584
 
1306
- it('collapses a leftover hooks.json onto inline [hooks] and says both Stops will run', async () => {
1585
+ it('preserves a foreign inline Stop while collapsing our duplicate definition', async () => {
1307
1586
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-collapse-'))
1308
1587
  const toml = writeInlineStop(cwd, 'gdh-stop')
1309
1588
  const json = path.join(cwd, '.codex', 'hooks.json')
@@ -1485,7 +1764,11 @@ describe('harness activation guidance', () => {
1485
1764
  const deps = {
1486
1765
  ...makeDeps(io, {} as ApiClient),
1487
1766
  cwd,
1488
- env: { XDG_STATE_HOME: path.join(cwd, 'state') },
1767
+ env: {
1768
+ HOME: path.join(cwd, 'home'),
1769
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1770
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
1771
+ },
1489
1772
  }
1490
1773
 
1491
1774
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
@@ -2120,6 +2403,7 @@ describe('init', () => {
2120
2403
  platform: 'ios' as const,
2121
2404
  permission_status: 'authorized',
2122
2405
  registration_healthy: true,
2406
+ reply_protocol_version: 2,
2123
2407
  last_seen_at: '2026-08-05T18:00:00.000Z',
2124
2408
  }
2125
2409
 
@@ -2492,8 +2776,8 @@ describe('init', () => {
2492
2776
  )
2493
2777
  expect(wired).toEqual(['claude-code', 'codex'])
2494
2778
  expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
2495
- expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
2496
- expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
2779
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(true)
2780
+ expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
2497
2781
  expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
2498
2782
  expect(io.outLines.join('\n')).toContain('Installed codex hooks')
2499
2783
  })
@@ -2664,6 +2948,7 @@ describe('init', () => {
2664
2948
  XDG_STATE_HOME: path.join(cwd, 'state'),
2665
2949
  CODEX_HOME: path.join(cwd, 'codex'),
2666
2950
  CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
2951
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode'),
2667
2952
  },
2668
2953
  }
2669
2954
 
@@ -2753,6 +3038,9 @@ describe('init', () => {
2753
3038
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
2754
3039
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
2755
3040
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
3041
+ expect(io.outLines.join('\n')).toContain(
3042
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone.",
3043
+ )
2756
3044
  expect(io.outLines.join('\n')).toContain('All set.')
2757
3045
  expect(submitCalls).toBe(1)
2758
3046
  expect(submittedDraft?.draft.event).toBe('setup_verified')
@@ -2895,6 +3183,8 @@ describe('init', () => {
2895
3183
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
2896
3184
  expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
2897
3185
  expect(io.outLines.join('\n')).toContain('Proof may still arrive')
3186
+ const proofDir = path.join(stateDir(deps.env), 'setup-proofs')
3187
+ expect(readdirSync(proofDir).some((name) => name.endsWith('.json'))).toBe(true)
2898
3188
 
2899
3189
  io.outLines = []
2900
3190
  io.errLines = []
@@ -3304,6 +3594,27 @@ describe('an outage is not an answer', () => {
3304
3594
  describe('asking before the hooks have ever run', () => {
3305
3595
  const execPath = process.execPath
3306
3596
  const scriptPath = fileURLToPath(import.meta.url)
3597
+ const scratch: string[] = []
3598
+
3599
+ function scratchDir(prefix: string): string {
3600
+ const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
3601
+ scratch.push(dir)
3602
+ return dir
3603
+ }
3604
+
3605
+ afterEach(() => {
3606
+ while (scratch.length > 0) {
3607
+ rmSync(scratch.pop()!, { recursive: true, force: true })
3608
+ }
3609
+ })
3610
+
3611
+ it('refuses to write Codex trust outside the isolated test account', () => {
3612
+ expect(() => trustInstalledCodexHooks(os.tmpdir(), {})).toThrow(/requires env.HOME/)
3613
+ expect(() => trustInstalledCodexHooks(os.tmpdir(), { HOME: path.dirname(os.tmpdir()) })).toThrow(
3614
+ /will only write under/,
3615
+ )
3616
+ expect(os.homedir()).toBe(process.env['NOTIFAI_TEST_HOME'])
3617
+ })
3307
3618
 
3308
3619
  it('names the active Codex harness instead of unrelated installed adapters', () => {
3309
3620
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
@@ -3311,6 +3622,7 @@ describe('asking before the hooks have ever run', () => {
3311
3622
  const env = {
3312
3623
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3313
3624
  XDG_STATE_HOME: path.join(cwd, 'state'),
3625
+ HOME: path.join(cwd, 'home'),
3314
3626
  CODEX_HOME: path.join(cwd, 'codex-home'),
3315
3627
  CODEX_THREAD_ID: 'codex-current-thread',
3316
3628
  CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
@@ -3335,6 +3647,7 @@ describe('asking before the hooks have ever run', () => {
3335
3647
  const env = {
3336
3648
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3337
3649
  XDG_STATE_HOME: path.join(cwd, 'state'),
3650
+ HOME: path.join(cwd, 'home'),
3338
3651
  CODEX_HOME: path.join(cwd, 'codex-home'),
3339
3652
  CODEX_THREAD_ID: 'codex-current-thread',
3340
3653
  }
@@ -3353,11 +3666,12 @@ describe('asking before the hooks have ever run', () => {
3353
3666
  })
3354
3667
 
3355
3668
  it('registers only when the active Codex thread owns the project pointer', () => {
3356
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-matching-'))
3669
+ const cwd = scratchDir('notifai-active-codex-matching-')
3357
3670
  const io = new CapturedIo()
3358
3671
  const env = {
3359
3672
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3360
3673
  XDG_STATE_HOME: path.join(cwd, 'state'),
3674
+ HOME: path.join(cwd, 'home'),
3361
3675
  CODEX_HOME: path.join(cwd, 'codex-home'),
3362
3676
  CODEX_THREAD_ID: 'codex-current-thread',
3363
3677
  }
@@ -3497,11 +3811,12 @@ describe('asking before the hooks have ever run', () => {
3497
3811
  })
3498
3812
 
3499
3813
  it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
3500
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-commitment-'))
3814
+ const cwd = scratchDir('notifai-ask-commitment-')
3501
3815
  const io = new CapturedIo()
3502
3816
  const env = {
3503
3817
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3504
3818
  XDG_STATE_HOME: path.join(cwd, 'state'),
3819
+ HOME: path.join(cwd, 'home'),
3505
3820
  CODEX_HOME: path.join(cwd, 'codex-home'),
3506
3821
  CODEX_THREAD_ID: 'codex-commitment-thread',
3507
3822
  }
@@ -3532,11 +3847,12 @@ describe('asking before the hooks have ever run', () => {
3532
3847
  })
3533
3848
 
3534
3849
  it('requires a concrete fallback for free-text answers before the turn ends', () => {
3535
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-free-text-'))
3850
+ const cwd = scratchDir('notifai-ask-free-text-')
3536
3851
  const io = new CapturedIo()
3537
3852
  const env = {
3538
3853
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3539
3854
  XDG_STATE_HOME: path.join(cwd, 'state'),
3855
+ HOME: path.join(cwd, 'home'),
3540
3856
  CODEX_HOME: path.join(cwd, 'codex-home'),
3541
3857
  CODEX_THREAD_ID: 'codex-free-text-thread',
3542
3858
  }
@@ -3555,11 +3871,12 @@ describe('asking before the hooks have ever run', () => {
3555
3871
  })
3556
3872
 
3557
3873
  it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
3558
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-stale-trust-'))
3874
+ const cwd = scratchDir('notifai-codex-stale-trust-')
3559
3875
  const io = new CapturedIo()
3560
3876
  const env = {
3561
3877
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3562
3878
  XDG_STATE_HOME: path.join(cwd, 'state'),
3879
+ HOME: path.join(cwd, 'home'),
3563
3880
  CODEX_HOME: path.join(cwd, 'codex-home'),
3564
3881
  CODEX_THREAD_ID: 'codex-current-thread',
3565
3882
  }
@@ -3570,7 +3887,7 @@ describe('asking before the hooks have ever run', () => {
3570
3887
  .find((installation) => installation.harness === 'codex')
3571
3888
  ?.handlers.find((handler) => handler.event === 'Stop')
3572
3889
  expect(stop).toBeDefined()
3573
- const configFile = path.join(env.CODEX_HOME, 'config.toml')
3890
+ const configFile = path.join(env.CODEX_HOME!, 'config.toml')
3574
3891
  writeFileSync(
3575
3892
  configFile,
3576
3893
  readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
@@ -3594,6 +3911,7 @@ describe('asking before the hooks have ever run', () => {
3594
3911
  const env = {
3595
3912
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3596
3913
  XDG_STATE_HOME: path.join(cwd, 'state'),
3914
+ HOME: path.join(cwd, 'home'),
3597
3915
  CODEX_HOME: path.join(cwd, 'codex-home'),
3598
3916
  CODEX_THREAD_ID: 'codex-current-thread',
3599
3917
  CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
@@ -3702,7 +4020,7 @@ describe('asking before the hooks have ever run', () => {
3702
4020
  })
3703
4021
 
3704
4022
  it('rejects duplicate active Codex definitions before registration', () => {
3705
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-duplicate-'))
4023
+ const cwd = scratchDir('notifai-active-codex-duplicate-')
3706
4024
  const io = new CapturedIo()
3707
4025
  const env = {
3708
4026
  HOME: path.join(cwd, 'home'),
@@ -3713,7 +4031,8 @@ describe('asking before the hooks have ever run', () => {
3713
4031
  }
3714
4032
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3715
4033
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3716
- applyPlan(path.join(env.CODEX_HOME, 'config.toml'), {
4034
+ mkdirSync(env.CODEX_HOME!, { recursive: true })
4035
+ applyPlan(path.join(env.CODEX_HOME!, 'config.toml'), {
3717
4036
  hooks: buildHookConfig({
3718
4037
  adapterPath: hookAdapterPath(deps.hookAdapterHome),
3719
4038
  harness: 'codex',
@@ -3822,6 +4141,7 @@ describe('asking before the hooks have ever run', () => {
3822
4141
  const env = {
3823
4142
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3824
4143
  XDG_STATE_HOME: path.join(cwd, 'state'),
4144
+ HOME: path.join(cwd, 'home'),
3825
4145
  CODEX_HOME: path.join(cwd, 'codex-home'),
3826
4146
  CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
3827
4147
  CLAUDECODE: '1',
@@ -3844,6 +4164,7 @@ describe('asking before the hooks have ever run', () => {
3844
4164
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
3845
4165
  XDG_STATE_HOME: path.join(cwd, 'state'),
3846
4166
  CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
4167
+ HOME: path.join(cwd, 'home'),
3847
4168
  CODEX_HOME: path.join(cwd, 'codex-home'),
3848
4169
  CLAUDECODE: '1',
3849
4170
  CLAUDE_CODE_SESSION_ID: 'claude-current',
@@ -4026,7 +4347,12 @@ describe('asking before the hooks have ever run', () => {
4026
4347
  ...isolatedEnv(cwd),
4027
4348
  CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
4028
4349
  }
4029
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
4350
+ const deps = {
4351
+ ...makeDeps(io, {} as ApiClient),
4352
+ cwd,
4353
+ env,
4354
+ hookPlatform: 'darwin' as NodeJS.Platform,
4355
+ }
4030
4356
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
4031
4357
 
4032
4358
  io.outLines = []
@@ -4037,14 +4363,8 @@ describe('asking before the hooks have ever run', () => {
4037
4363
  io.outLines = []
4038
4364
  await doctorCommand(deps, {})
4039
4365
  const out = io.outLines.join('\n')
4040
- if (process.platform === 'darwin') {
4041
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
4042
- expect(out).toContain('can prove a stopped thread unowned before resuming it')
4043
- } else {
4044
- // Linux has no non-blocking `flock` through Node's `open(2)` flags, so
4045
- // the gate stays shut and doctor says so rather than implying a wake.
4046
- expect(out).toContain('nothing can be resumed')
4047
- }
4366
+ expect(out).toMatch(/ok\s+hooks \(wake route\)/)
4367
+ expect(out).toContain('can prove a stopped thread unowned before resuming it')
4048
4368
  })
4049
4369
 
4050
4370
  it('documents the failing exit contract in doctor JSON', async () => {
@@ -4123,6 +4443,7 @@ describe('asking before the hooks have ever run', () => {
4123
4443
  XDG_STATE_HOME: cwd,
4124
4444
  CODEX_HOME: path.join(cwd, 'none'),
4125
4445
  CLAUDE_CONFIG_DIR: path.join(cwd, 'none'),
4446
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'none'),
4126
4447
  },
4127
4448
  }
4128
4449