@raidiant/notifai 1.0.1 → 1.1.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 (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +31 -39
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config.d.ts +2 -2
  7. package/dist/config.js +5 -9
  8. package/dist/config.js.map +1 -1
  9. package/dist/credentials.d.ts +54 -3
  10. package/dist/credentials.js +237 -18
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/file-lock.js +15 -6
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/hook-adapter.d.ts +6 -2
  15. package/dist/hook-adapter.js +110 -29
  16. package/dist/hook-adapter.js.map +1 -1
  17. package/dist/hooks.js +2 -1
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +50 -21
  20. package/dist/install-hooks.js +141 -57
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/native-skills.js +5 -10
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/opencode-plugin.d.ts +7 -2
  25. package/dist/opencode-plugin.js +142 -4
  26. package/dist/opencode-plugin.js.map +1 -1
  27. package/dist/platform.d.ts +73 -0
  28. package/dist/platform.js +143 -0
  29. package/dist/platform.js.map +1 -0
  30. package/dist/ui/config-view.js +5 -1
  31. package/dist/ui/config-view.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands.test.ts +100 -26
  34. package/src/commands.ts +49 -36
  35. package/src/config.ts +11 -9
  36. package/src/credentials.test.ts +291 -0
  37. package/src/credentials.ts +274 -17
  38. package/src/file-lock.ts +34 -9
  39. package/src/hook-adapter.test.ts +136 -0
  40. package/src/hook-adapter.ts +120 -28
  41. package/src/hooks.test.ts +27 -1
  42. package/src/hooks.ts +2 -1
  43. package/src/install-hooks.test.ts +174 -33
  44. package/src/install-hooks.ts +186 -54
  45. package/src/native-skills.ts +5 -10
  46. package/src/opencode-plugin.ts +148 -3
  47. package/src/platform.test.ts +152 -0
  48. package/src/platform.ts +173 -0
  49. package/src/ui/config-view.ts +5 -5
@@ -12,8 +12,11 @@ import { atomicWriteFileSync } from './atomic-file.js'
12
12
  import { withTargetFileLock } from './file-lock.js'
13
13
 
14
14
  const ADAPTER_MARKER = '# notifai managed hook adapter'
15
+ const WIN32_ADAPTER_MARKER = '// notifai managed hook adapter'
15
16
  const ADAPTER_VERSION = 1
16
17
 
18
+ export type HookHostPlatform = 'posix' | 'win32'
19
+
17
20
  export interface HookAdapterFileTarget {
18
21
  kind?: 'file'
19
22
  execPath: string
@@ -39,6 +42,12 @@ export interface HookAdapterInspection {
39
42
  problems: string[]
40
43
  }
41
44
 
45
+ export function hookHostPlatform(
46
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
47
+ ): HookHostPlatform {
48
+ return platform === 'win32' ? 'win32' : 'posix'
49
+ }
50
+
42
51
  /**
43
52
  * One user-level identity for every harness and every project/global install.
44
53
  *
@@ -57,18 +66,21 @@ export function hookAdapterPath(homeDir: string = os.userInfo().homedir): string
57
66
  export function installHookAdapter(
58
67
  target: HookAdapterTarget,
59
68
  homeDir?: string,
69
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
60
70
  ): { path: string; changed: boolean } {
61
- assertUsableTarget(target)
71
+ const host = hookHostPlatform(platform)
72
+ assertUsableTarget(target, host)
62
73
  const file = hookAdapterPath(homeDir)
63
- ensureManagedDirectories(homeDir ?? os.userInfo().homedir)
64
- const source = hookAdapterSource(target)
74
+ ensureManagedDirectories(homeDir ?? os.userInfo().homedir, host)
75
+ const source = hookAdapterSource(target, host)
65
76
  return withTargetFileLock(file, () => {
66
77
  const existing = existsSync(file) ? readSafeManagedFile(file) : null
67
- const changed =
68
- existing === null || existing.contents !== source || (existing.mode & 0o777) !== 0o700
78
+ const modeDrift =
79
+ host === 'posix' && existing !== null && (existing.mode & 0o777) !== 0o700
80
+ const changed = existing === null || existing.contents !== source || modeDrift
69
81
  if (changed) {
70
82
  atomicWriteFileSync(file, source, {
71
- mode: 0o700,
83
+ mode: host === 'posix' ? 0o700 : 0o600,
72
84
  preserveMode: false,
73
85
  requireCurrentUserOwner: true,
74
86
  })
@@ -77,14 +89,14 @@ export function installHookAdapter(
77
89
  })
78
90
  }
79
91
 
80
- function assertUsableTarget(target: HookAdapterTarget): void {
92
+ function assertUsableTarget(target: HookAdapterTarget, host: HookHostPlatform): void {
81
93
  let exec
82
94
  try {
83
95
  exec = statSync(target.execPath)
84
96
  } catch {
85
97
  throw new Error(`Hook adapter runtime ${target.execPath} does not exist.`)
86
98
  }
87
- if (!exec.isFile() || (exec.mode & 0o111) === 0) {
99
+ if (!exec.isFile() || (host === 'posix' && (exec.mode & 0o111) === 0)) {
88
100
  throw new Error(`Hook adapter runtime ${target.execPath} is not executable.`)
89
101
  }
90
102
  if (isNpxAdapterTarget(target)) {
@@ -114,7 +126,9 @@ function assertUsableTarget(target: HookAdapterTarget): void {
114
126
  /** Inspect the shared adapter without executing either it or its registered CLI. */
115
127
  export function inspectHookAdapter(
116
128
  homeDir?: string,
129
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
117
130
  ): HookAdapterInspection {
131
+ const host = hookHostPlatform(platform)
118
132
  const file = hookAdapterPath(homeDir)
119
133
  if (!existsSync(file)) {
120
134
  return { path: file, target: null, problems: [`${file} is missing; rerun \`notifai hooks install\``] }
@@ -135,19 +149,24 @@ export function inspectHookAdapter(
135
149
  }
136
150
 
137
151
  const problems: string[] = []
138
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
139
- if (uid !== undefined && stat.uid !== uid) {
140
- problems.push(`${file} is owned by uid ${stat.uid}, not the current user`)
141
- }
142
- if ((stat.mode & 0o077) !== 0 || (stat.mode & 0o700) !== 0o700) {
143
- problems.push(`${file} permissions are ${(stat.mode & 0o777).toString(8)}, expected 700`)
152
+ if (host === 'posix') {
153
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
154
+ if (uid !== undefined && stat.uid !== uid) {
155
+ problems.push(`${file} is owned by uid ${stat.uid}, not the current user`)
156
+ }
157
+ if ((stat.mode & 0o077) !== 0 || (stat.mode & 0o700) !== 0o700) {
158
+ problems.push(`${file} permissions are ${(stat.mode & 0o777).toString(8)}, expected 700`)
159
+ }
144
160
  }
145
161
 
146
162
  const contents = readFileSync(file, 'utf8')
147
- if (!contents.startsWith(`#!/bin/sh\n${ADAPTER_MARKER}\n`)) {
163
+ const format = adapterFormat(contents)
164
+ if (format === null) {
148
165
  problems.push(`${file} has no managed adapter marker`)
166
+ } else if (format !== host) {
167
+ problems.push(`${file} is not a ${host} managed adapter; rerun \`notifai hooks install\``)
149
168
  }
150
- const version = Number(/^# adapter-version: (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
169
+ const version = Number(meta(contents, 'adapter-version') ?? Number.NaN)
151
170
  if (version !== ADAPTER_VERSION) problems.push(`${file} uses obsolete adapter version ${version}`)
152
171
  const target = parseTarget(contents)
153
172
  if (target === null) {
@@ -155,7 +174,7 @@ export function inspectHookAdapter(
155
174
  } else {
156
175
  try {
157
176
  const exec = statSync(target.execPath)
158
- if (!exec.isFile() || (exec.mode & 0o111) === 0) {
177
+ if (!exec.isFile() || (host === 'posix' && (exec.mode & 0o111) === 0)) {
159
178
  problems.push(`registered runtime ${target.execPath} is not executable`)
160
179
  }
161
180
  } catch {
@@ -183,7 +202,17 @@ export function inspectHookAdapter(
183
202
  return { path: file, target, problems }
184
203
  }
185
204
 
186
- function hookAdapterSource(target: HookAdapterTarget): string {
205
+ /** Generate the managed adapter source for one host. Exported so tests can pin bytes. */
206
+ export function hookAdapterSource(
207
+ target: HookAdapterTarget,
208
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
209
+ ): string {
210
+ return hookHostPlatform(platform) === 'win32'
211
+ ? win32HookAdapterSource(target)
212
+ : posixHookAdapterSource(target)
213
+ }
214
+
215
+ function posixHookAdapterSource(target: HookAdapterTarget): string {
187
216
  if (isNpxAdapterTarget(target)) {
188
217
  return `#!/bin/sh
189
218
  ${ADAPTER_MARKER}
@@ -225,16 +254,77 @@ exit 127
225
254
  `
226
255
  }
227
256
 
257
+ function win32HookAdapterSource(target: HookAdapterTarget): string {
258
+ const header = isNpxAdapterTarget(target)
259
+ ? `${WIN32_ADAPTER_MARKER}
260
+ // adapter-version: ${ADAPTER_VERSION}
261
+ // target-kind: npx
262
+ // target-exec-json: ${JSON.stringify(target.execPath)}
263
+ // target-npm-cli-json: ${JSON.stringify(target.npmCli)}
264
+ // target-spec-json: ${JSON.stringify(target.spec)}
265
+ `
266
+ : `${WIN32_ADAPTER_MARKER}
267
+ // adapter-version: ${ADAPTER_VERSION}
268
+ // target-exec-json: ${JSON.stringify(target.execPath)}
269
+ // target-script-json: ${JSON.stringify(target.scriptPath)}
270
+ `
271
+ const argv = isNpxAdapterTarget(target)
272
+ ? `[${JSON.stringify(target.npmCli)}, "exec", "--yes", "--package", ${JSON.stringify(target.spec)}, "--", "notifai", ...process.argv.slice(2)]`
273
+ : `[${JSON.stringify(target.scriptPath)}, ...process.argv.slice(2)]`
274
+ const companion = isNpxAdapterTarget(target)
275
+ ? JSON.stringify(target.npmCli)
276
+ : JSON.stringify(target.scriptPath)
277
+ return `${header}"use strict";
278
+ const { spawnSync } = require("node:child_process");
279
+ const { statSync } = require("node:fs");
280
+ const registeredExec = ${JSON.stringify(target.execPath)};
281
+ const registeredCompanion = ${companion};
282
+
283
+ function isFile(file) {
284
+ try {
285
+ return statSync(file).isFile();
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ if (isFile(registeredExec) && isFile(registeredCompanion)) {
292
+ const result = spawnSync(registeredExec, ${argv}, {
293
+ stdio: "inherit",
294
+ windowsHide: true,
295
+ });
296
+ if (result.error) {
297
+ process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
298
+ process.exit(127);
299
+ }
300
+ process.exit(result.status === null ? 1 : result.status);
301
+ }
302
+
303
+ process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
304
+ process.exit(127);
305
+ `
306
+ }
307
+
308
+ function adapterFormat(contents: string): HookHostPlatform | null {
309
+ if (contents.startsWith(`#!/bin/sh\n${ADAPTER_MARKER}\n`)) return 'posix'
310
+ if (contents.startsWith(`${WIN32_ADAPTER_MARKER}\n`)) return 'win32'
311
+ return null
312
+ }
313
+
314
+ function meta(contents: string, key: string): string | undefined {
315
+ return new RegExp(`^(?:#|//) ${key}: (.+)$`, 'm').exec(contents)?.[1]
316
+ }
317
+
228
318
  function parseTarget(contents: string): HookAdapterTarget | null {
229
- const kind = /^# target-kind: (.+)$/m.exec(contents)?.[1]
230
- const exec = /^# target-exec-json: (.+)$/m.exec(contents)?.[1]
319
+ const kind = meta(contents, 'target-kind')
320
+ const exec = meta(contents, 'target-exec-json')
231
321
  if (exec === undefined) return null
232
322
  try {
233
323
  const execPath: unknown = JSON.parse(exec)
234
324
  if (typeof execPath !== 'string') return null
235
325
  if (kind === 'npx') {
236
- const npmCliRaw = /^# target-npm-cli-json: (.+)$/m.exec(contents)?.[1]
237
- const specRaw = /^# target-spec-json: (.+)$/m.exec(contents)?.[1]
326
+ const npmCliRaw = meta(contents, 'target-npm-cli-json')
327
+ const specRaw = meta(contents, 'target-spec-json')
238
328
  if (npmCliRaw === undefined || specRaw === undefined) return null
239
329
  const npmCli: unknown = JSON.parse(npmCliRaw)
240
330
  const spec: unknown = JSON.parse(specRaw)
@@ -242,7 +332,7 @@ function parseTarget(contents: string): HookAdapterTarget | null {
242
332
  ? { kind: 'npx', execPath, npmCli, spec }
243
333
  : null
244
334
  }
245
- const script = /^# target-script-json: (.+)$/m.exec(contents)?.[1]
335
+ const script = meta(contents, 'target-script-json')
246
336
  if (script === undefined) return null
247
337
  const scriptPath: unknown = JSON.parse(script)
248
338
  return typeof scriptPath === 'string' ? { execPath, scriptPath } : null
@@ -255,7 +345,7 @@ function quote(value: string): string {
255
345
  return `'${value.replaceAll("'", `'\\''`)}'`
256
346
  }
257
347
 
258
- function ensureManagedDirectories(homeDir: string): void {
348
+ function ensureManagedDirectories(homeDir: string, host: HookHostPlatform): void {
259
349
  if (!existsSync(homeDir)) mkdirSync(homeDir, { recursive: true, mode: 0o700 })
260
350
  for (const dir of [path.join(homeDir, '.notifai'), path.join(homeDir, '.notifai', 'bin')]) {
261
351
  if (!existsSync(dir)) mkdirSync(dir, { mode: 0o700 })
@@ -263,11 +353,13 @@ function ensureManagedDirectories(homeDir: string): void {
263
353
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
264
354
  throw new Error(`${dir} is not a regular directory; refusing to install the hook adapter.`)
265
355
  }
266
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
267
- if (uid !== undefined && stat.uid !== uid) {
268
- throw new Error(`${dir} is owned by uid ${stat.uid}, not the current user.`)
356
+ if (host === 'posix') {
357
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
358
+ if (uid !== undefined && stat.uid !== uid) {
359
+ throw new Error(`${dir} is owned by uid ${stat.uid}, not the current user.`)
360
+ }
361
+ chmodSync(dir, 0o700)
269
362
  }
270
- chmodSync(dir, 0o700)
271
363
  }
272
364
  }
273
365
 
package/src/hooks.test.ts CHANGED
@@ -115,6 +115,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
115
115
  platform: 'ios' as const,
116
116
  permission_status: 'authorized',
117
117
  registration_healthy: true,
118
+ reply_protocol_version: 1,
118
119
  last_seen_at: null,
119
120
  },
120
121
  {
@@ -123,6 +124,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
123
124
  platform: 'macos' as const,
124
125
  permission_status: 'authorized',
125
126
  registration_healthy: true,
127
+ reply_protocol_version: 1,
126
128
  last_seen_at: null,
127
129
  },
128
130
  ],
@@ -716,6 +718,30 @@ describe('terminal-first grace window', () => {
716
718
  ).toBeGreaterThanOrEqual(60)
717
719
  })
718
720
 
721
+ it('never pushes a question to a Companion without the current reply protocol', async () => {
722
+ const h = harness([])
723
+ const factory = h.deps.clientFactory
724
+ h.deps.clientFactory = () => {
725
+ const client = factory!()
726
+ return {
727
+ ...client,
728
+ listDevices: async () => ({
729
+ devices: (await client.listDevices()).devices.map((device) => ({
730
+ ...device,
731
+ reply_protocol_version: null,
732
+ })),
733
+ }),
734
+ } as ApiClient
735
+ }
736
+ writeSessionState('old-companion', h.env, { last_prompt_at: AWAY })
737
+ registerQuestion('old-companion', h.env, { question: 'Ship it?' }, NOW)
738
+
739
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'old-companion' }))
740
+
741
+ expect(h.recorder.submitted).toHaveLength(0)
742
+ expect(h.io.errLines.join('\n')).toContain('no device can answer a question yet')
743
+ })
744
+
719
745
  it('charges transport overrun and close time to the one waiter ceiling', async () => {
720
746
  const h = harness([])
721
747
  const factory = h.deps.clientFactory
@@ -2247,7 +2273,7 @@ describe('session-end hook', () => {
2247
2273
  await waitUntil(
2248
2274
  () =>
2249
2275
  readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
2250
- 700,
2276
+ 5_000,
2251
2277
  'the real Codex hook did not reach contended diagnostics before its deadline',
2252
2278
  )
2253
2279
 
package/src/hooks.ts CHANGED
@@ -976,7 +976,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
976
976
  .filter(
977
977
  (device) =>
978
978
  (device.platform === 'ios' || device.platform === 'macos') &&
979
- device.registration_healthy,
979
+ device.registration_healthy &&
980
+ device.reply_protocol_version === 1,
980
981
  )
981
982
  .filter((device) => configured === null || configured.includes(device.device_id))
982
983
  .map((device) => device.device_id)
@@ -10,6 +10,7 @@ import {
10
10
  } from 'node:fs'
11
11
  import os from 'node:os'
12
12
  import path from 'node:path'
13
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
13
14
  import { describe, expect, it } from 'vitest'
14
15
  import { parseChoices } from './send.js'
15
16
  import {
@@ -19,7 +20,9 @@ import {
19
20
  opencodePluginSource,
20
21
  opencodePluginTarget,
21
22
  } from './opencode-plugin.js'
23
+ import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
22
24
  import {
25
+ BLOCKING_STOP_TIMEOUT_SECONDS,
23
26
  HARNESSES,
24
27
  applyPlan,
25
28
  detectHarness,
@@ -33,7 +36,9 @@ import {
33
36
  codexTrustKey,
34
37
  codexTrustProblems,
35
38
  handlerEvent,
39
+ harnessAccountHome,
36
40
  hookCommand,
41
+ quoteWindowsArg,
37
42
  loadSettings,
38
43
  mergeHooks,
39
44
  removeHooks,
@@ -126,7 +131,7 @@ describe('hook config', () => {
126
131
  })
127
132
  })
128
133
 
129
- it('gives Claude Code an asynchronous Stop with an explicit waiter budget', () => {
134
+ it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
130
135
  const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
131
136
 
132
137
  // `async: true` is what frees the turn: the handler returns at once and the
@@ -143,6 +148,22 @@ describe('hook config', () => {
143
148
  expect(claude['SessionEnd']?.[0]?.hooks[0]?.async).toBeUndefined()
144
149
  })
145
150
 
151
+ it('gives Claude Code a blocking Stop continuation on Windows', () => {
152
+ const options = {
153
+ adapterPath: 'C:\\Users\\Ada\\.notifai\\bin\\hook-adapter',
154
+ harness: 'claude-code' as const,
155
+ platform: 'win32' as const,
156
+ nodePath: 'C:\\Program Files\\nodejs\\node.exe',
157
+ }
158
+ const claude = buildHookConfig(options)
159
+
160
+ expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
161
+ type: 'command',
162
+ command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
163
+ timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
164
+ })
165
+ })
166
+
146
167
  it('leaves exactly one handler per event when an old blocking shape is reinstalled over', () => {
147
168
  const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
148
169
  const migrated = mergeHooks(
@@ -226,18 +247,37 @@ describe('settings locations', () => {
226
247
  )
227
248
  })
228
249
 
229
- it('uses config.toml for Codex, not the legacy hooks.json', () => {
230
- expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
250
+ it('keeps ambient global settings inside the test account', () => {
251
+ expect(os.homedir()).toBe(process.env['HOME'])
252
+ expect(os.homedir()).toBe(process.env['NOTIFAI_TEST_HOME'])
253
+ expect(settingsFile('claude-code', true, '/repo', {})).toBe(
254
+ path.join(process.env['HOME']!, '.claude', 'settings.json'),
255
+ )
231
256
  expect(settingsFile('codex', true, '/repo', {})).toBe(
232
- path.join(os.homedir(), '.codex', 'config.toml'),
257
+ path.join(process.env['HOME']!, '.codex', 'hooks.json'),
233
258
  )
234
259
  })
235
260
 
236
- it('migrates a leftover Notifai-only hooks.json onto config.toml', () => {
237
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-migrate-json-'))
261
+ it('uses the dedicated Codex hooks file for a new layer', () => {
262
+ expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
263
+ expect(settingsFile('codex', true, '/repo', {})).toBe(
264
+ path.join(os.homedir(), '.codex', 'hooks.json'),
265
+ )
266
+ })
267
+
268
+ it('keeps a Notifai-only hooks.json instead of rewriting config.toml', () => {
269
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-'))
238
270
  mkdirSync(path.join(repo, '.codex'), { recursive: true })
239
271
  applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
240
272
 
273
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
274
+ })
275
+
276
+ it('keeps a Notifai-only inline representation so existing trust remains valid', () => {
277
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-ours-toml-'))
278
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
279
+ applyPlan(path.join(repo, '.codex', 'config.toml'), { hooks: ours() })
280
+
241
281
  expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
242
282
  })
243
283
 
@@ -282,17 +322,18 @@ describe('settings locations', () => {
282
322
 
283
323
  it('does not treat the Codex trust store as an inline hook representation', () => {
284
324
  const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
325
+ mkdirSync(path.join(home, '.codex'), { recursive: true })
285
326
  writeFileSync(
286
- path.join(home, 'config.toml'),
287
- `[hooks.state."${home}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
327
+ path.join(home, '.codex', 'config.toml'),
328
+ `[hooks.state."${home}/.codex/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
288
329
  )
289
330
 
290
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
291
- path.join(home, 'config.toml'),
331
+ expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
332
+ path.join(home, '.codex', 'hooks.json'),
292
333
  )
293
334
  })
294
335
 
295
- it('keeps writing to inline [hooks] when a leftover hooks.json only holds Notifai', () => {
336
+ it('preserves foreign inline hooks when both representations exist', () => {
296
337
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
297
338
  const layer = path.join(repo, '.codex')
298
339
  mkdirSync(layer, { recursive: true })
@@ -330,7 +371,7 @@ describe('a Codex install run inside a git worktree', () => {
330
371
  it('targets the main repository, which is the file Codex actually reads', () => {
331
372
  const { main, worktree } = repoWithWorktree()
332
373
 
333
- expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
374
+ expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'hooks.json'))
334
375
  expect(codexProjectRoot(worktree)).toBe(main)
335
376
  })
336
377
 
@@ -395,19 +436,13 @@ describe('a Codex install run inside a git worktree', () => {
395
436
  )
396
437
  })
397
438
 
398
- it('follows a relocated config home rather than installing where nothing reads', () => {
399
- // Orca points CODEX_HOME at a per-account home; writing to ~/.codex there
400
- // produced hooks Codex never loaded, and nothing reported a problem.
401
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
402
- '/managed/codex/config.toml',
439
+ it('follows the active harness home for global hook install', () => {
440
+ expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
441
+ '/managed/codex/hooks.json',
403
442
  )
404
443
  expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
405
444
  '/managed/claude/settings.json',
406
445
  )
407
- // An empty value is not a relocation.
408
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
409
- path.join(os.homedir(), '.codex', 'config.toml'),
410
- )
411
446
  })
412
447
  })
413
448
 
@@ -560,7 +595,7 @@ describe('finding what is installed', () => {
560
595
  ].join('\n'),
561
596
  )
562
597
 
563
- const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
598
+ const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
564
599
  const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
565
600
  expect(codex?.file).toBe(file)
566
601
  expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
@@ -580,7 +615,7 @@ describe('finding what is installed', () => {
580
615
  ),
581
616
  )
582
617
 
583
- const problems = codexRepresentationProblems(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
618
+ const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
584
619
  expect(problems).toHaveLength(1)
585
620
  expect(problems[0]).toContain('loading hooks from both')
586
621
  expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
@@ -594,7 +629,7 @@ describe('finding what is installed', () => {
594
629
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
595
630
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
596
631
 
597
- const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
632
+ const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
598
633
  const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
599
634
 
600
635
  expect(claude).toBeDefined()
@@ -615,39 +650,41 @@ describe('finding what is installed', () => {
615
650
 
616
651
  it('detects when Codex still trusts an older definition of an installed hook', () => {
617
652
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
618
- const codexHome = path.join(cwd, 'codex-home')
653
+ const home = path.join(cwd, 'home')
654
+ const env = { HOME: home }
619
655
  const hookFile = path.join(cwd, '.codex', 'hooks.json')
620
656
  mkdirSync(path.dirname(hookFile), { recursive: true })
621
657
  applyPlan(hookFile, { hooks: ours() })
622
- const installations = findInstallations(cwd, { CODEX_HOME: codexHome })
658
+ const installations = findInstallations(cwd, env)
623
659
  const codex = installations.find((installation) => installation.harness === 'codex')
624
660
  const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
625
661
  expect(stop).toBeDefined()
626
662
 
627
- mkdirSync(codexHome, { recursive: true })
663
+ const trustFile = path.join(home, '.codex', 'config.toml')
664
+ mkdirSync(path.dirname(trustFile), { recursive: true })
628
665
  writeFileSync(
629
- path.join(codexHome, 'config.toml'),
666
+ trustFile,
630
667
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
631
668
  )
632
669
 
633
- const stale = codexTrustProblems(installations, { CODEX_HOME: codexHome })
670
+ const stale = codexTrustProblems(installations, env)
634
671
  expect(stale).toHaveLength(3)
635
672
  expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
636
673
 
637
674
  writeFileSync(
638
- path.join(codexHome, 'config.toml'),
675
+ trustFile,
639
676
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
640
677
  )
641
- expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual([
678
+ expect(codexTrustProblems(installations, env)).toEqual([
642
679
  expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
643
680
  expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
644
681
  ])
645
682
 
646
683
  writeFileSync(
647
- path.join(codexHome, 'config.toml'),
684
+ trustFile,
648
685
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
649
686
  )
650
- expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual(
687
+ expect(codexTrustProblems(installations, env)).toEqual(
651
688
  expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
652
689
  )
653
690
  })
@@ -843,6 +880,14 @@ describe('detectHarness', () => {
843
880
  ])
844
881
  })
845
882
 
883
+ it('detects Codex from the active custom CODEX_HOME', () => {
884
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-'))
885
+ const codexHome = path.join(home, 'managed', 'codex')
886
+ mkdirSync(codexHome, { recursive: true })
887
+
888
+ expect(detectedHarnesses(project(), { HOME: home, CODEX_HOME: codexHome })).toEqual(['codex'])
889
+ })
890
+
846
891
  it('prefers project evidence over anything installed on the machine', () => {
847
892
  // The regression. Machine-global markers used to be OR-ed in per harness,
848
893
  // so a developer with several agent tools installed got a candidate for
@@ -865,3 +910,99 @@ describe('detectHarness', () => {
865
910
  expect(answer === null || HARNESSES.includes(answer)).toBe(true)
866
911
  })
867
912
  })
913
+
914
+ describe('Windows hook commands and discovery', () => {
915
+ const winNode = 'C:\\Program Files\\nodejs\\node.exe'
916
+ const winAdapter = 'C:\\Users\\Ada Lovelace\\.notifai\\bin\\hook-adapter'
917
+ const winOpts = { platform: 'win32' as const, nodePath: winNode }
918
+
919
+ it('keeps POSIX command bytes unchanged', () => {
920
+ expect(hookCommand(ADAPTER, 'stop', undefined, { platform: 'posix' })).toBe(
921
+ `'${ADAPTER}' hook stop --owner notifai`,
922
+ )
923
+ expect(hookCommand(ADAPTER, 'stop', 'codex', { platform: 'posix' })).toBe(
924
+ `'${ADAPTER}' hook stop --owner notifai --harness codex`,
925
+ )
926
+ })
927
+
928
+ it('emits a Windows-safe command with no POSIX single quotes', () => {
929
+ const command = hookCommand(winAdapter, 'stop', 'codex', winOpts)
930
+ expect(command).toBe(
931
+ `"${winNode}" "${winAdapter}" hook stop --owner notifai --harness codex`,
932
+ )
933
+ expect(command).not.toContain("'")
934
+ expect(quoteWindowsArg('C:\\ends\\with\\')).toBe('"C:\\ends\\with\\\\"')
935
+ expect(quoteWindowsArg('say "hi"')).toBe('"say \\"hi\\""')
936
+ })
937
+
938
+ it('round-trips the Windows command through JSON and TOML documents', () => {
939
+ const command = hookCommand(winAdapter, 'stop', 'cursor', winOpts)
940
+ expect(JSON.parse(JSON.stringify({ command }))).toEqual({ command })
941
+ expect(parseToml(stringifyToml({ command }))).toEqual({ command })
942
+ })
943
+
944
+ it('discovers a Windows-quoted installation as the stable adapter', () => {
945
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-win-discover-'))
946
+ const homeDir = path.join(cwd, 'home')
947
+ const script = path.join(cwd, 'cli.js')
948
+ writeFileSync(script, '')
949
+ installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32')
950
+ const adapter = hookAdapterPath(homeDir)
951
+ mkdirSync(path.join(cwd, '.claude'), { recursive: true })
952
+ applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
953
+ hooks: buildHookConfig({
954
+ adapterPath: adapter,
955
+ harness: 'claude-code',
956
+ platform: 'win32',
957
+ nodePath: process.execPath,
958
+ }),
959
+ })
960
+
961
+ const found = findInstallations(
962
+ cwd,
963
+ { HOME: homeDir, USERPROFILE: 'C:\\Users\\Ada' },
964
+ homeDir,
965
+ 'win32',
966
+ )
967
+ expect(found).toHaveLength(1)
968
+ expect(found[0]?.harness).toBe('claude-code')
969
+ expect(found[0]?.problems ?? []).toEqual([])
970
+ expect(found[0]?.handlers[0]?.command.startsWith(`${quoteWindowsArg(process.execPath)} `)).toBe(
971
+ true,
972
+ )
973
+ })
974
+
975
+ it('prefers a Windows-absolute USERPROFILE over an MSYS HOME', () => {
976
+ const env = { HOME: '/c/Users/msys', USERPROFILE: 'C:\\Users\\Ada' }
977
+ expect(harnessAccountHome(env, 'win32')).toBe('C:\\Users\\Ada')
978
+ expect(settingsFile('cursor', true, '/repo', env, 'win32')).toBe(
979
+ path.join('C:\\Users\\Ada', '.cursor', 'hooks.json'),
980
+ )
981
+ expect(settingsFile('claude-code', true, '/repo', env, 'win32')).toBe(
982
+ path.join('C:\\Users\\Ada', '.claude', 'settings.json'),
983
+ )
984
+ expect(opencodePluginPath(true, '/repo', env, 'win32')).toBe(
985
+ path.join('C:\\Users\\Ada', '.config', 'opencode', 'plugins', 'notifai.js'),
986
+ )
987
+ })
988
+
989
+ it('makes OpenCode spawn the adapter through Node on Windows with shell:false', () => {
990
+ const source = opencodePluginSource({
991
+ adapterPath: winAdapter,
992
+ timeoutSeconds: 240,
993
+ platform: 'win32',
994
+ nodePath: winNode,
995
+ })
996
+ expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
997
+ expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]')
998
+ expect(source).toContain('shell: false')
999
+ expect(source).toContain('windowsHide: true')
1000
+ expect(source).not.toContain('spawn(ADAPTER,')
1001
+ expect(opencodePluginTarget(source)).toEqual({
1002
+ adapter: winAdapter,
1003
+ current: true,
1004
+ timeoutSeconds: 240,
1005
+ nodePath: winNode,
1006
+ })
1007
+ })
1008
+ })