@raidiant/notifai 1.0.0 → 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 +26 -1
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +43 -38
  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 +54 -22
  20. package/dist/install-hooks.js +125 -56
  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 +144 -34
  34. package/src/commands.ts +65 -35
  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 +188 -27
  44. package/src/install-hooks.ts +177 -52
  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,13 +247,52 @@ describe('settings locations', () => {
226
247
  )
227
248
  })
228
249
 
229
- it('uses hooks.json for Codex', () => {
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
+ )
256
+ expect(settingsFile('codex', true, '/repo', {})).toBe(
257
+ path.join(process.env['HOME']!, '.codex', 'hooks.json'),
258
+ )
259
+ })
260
+
261
+ it('uses the dedicated Codex hooks file for a new layer', () => {
230
262
  expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
231
263
  expect(settingsFile('codex', true, '/repo', {})).toBe(
232
264
  path.join(os.homedir(), '.codex', 'hooks.json'),
233
265
  )
234
266
  })
235
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-'))
270
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
271
+ applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
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
+
281
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
282
+ })
283
+
284
+ it('stays on hooks.json when that file already holds someone else\'s hooks', () => {
285
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
286
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
287
+ applyPlan(path.join(repo, '.codex', 'hooks.json'), {
288
+ hooks: {
289
+ Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
290
+ },
291
+ })
292
+
293
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
294
+ })
295
+
236
296
  it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
237
297
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
238
298
  mkdirSync(path.join(repo, '.codex'), { recursive: true })
@@ -262,17 +322,18 @@ describe('settings locations', () => {
262
322
 
263
323
  it('does not treat the Codex trust store as an inline hook representation', () => {
264
324
  const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
325
+ mkdirSync(path.join(home, '.codex'), { recursive: true })
265
326
  writeFileSync(
266
- path.join(home, 'config.toml'),
267
- `[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`,
268
329
  )
269
330
 
270
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
271
- path.join(home, 'hooks.json'),
331
+ expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
332
+ path.join(home, '.codex', 'hooks.json'),
272
333
  )
273
334
  })
274
335
 
275
- it('keeps writing to inline [hooks] when a leftover hooks.json only holds Notifai', () => {
336
+ it('preserves foreign inline hooks when both representations exist', () => {
276
337
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
277
338
  const layer = path.join(repo, '.codex')
278
339
  mkdirSync(layer, { recursive: true })
@@ -375,19 +436,13 @@ describe('a Codex install run inside a git worktree', () => {
375
436
  )
376
437
  })
377
438
 
378
- it('follows a relocated config home rather than installing where nothing reads', () => {
379
- // Orca points CODEX_HOME at a per-account home; writing to ~/.codex there
380
- // produced hooks Codex never loaded, and nothing reported a problem.
381
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
439
+ it('follows the active harness home for global hook install', () => {
440
+ expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
382
441
  '/managed/codex/hooks.json',
383
442
  )
384
443
  expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
385
444
  '/managed/claude/settings.json',
386
445
  )
387
- // An empty value is not a relocation.
388
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
389
- path.join(os.homedir(), '.codex', 'hooks.json'),
390
- )
391
446
  })
392
447
  })
393
448
 
@@ -540,7 +595,7 @@ describe('finding what is installed', () => {
540
595
  ].join('\n'),
541
596
  )
542
597
 
543
- const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
598
+ const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
544
599
  const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
545
600
  expect(codex?.file).toBe(file)
546
601
  expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
@@ -560,7 +615,7 @@ describe('finding what is installed', () => {
560
615
  ),
561
616
  )
562
617
 
563
- const problems = codexRepresentationProblems(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
618
+ const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
564
619
  expect(problems).toHaveLength(1)
565
620
  expect(problems[0]).toContain('loading hooks from both')
566
621
  expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
@@ -574,7 +629,7 @@ describe('finding what is installed', () => {
574
629
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
575
630
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
576
631
 
577
- const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
632
+ const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
578
633
  const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
579
634
 
580
635
  expect(claude).toBeDefined()
@@ -595,39 +650,41 @@ describe('finding what is installed', () => {
595
650
 
596
651
  it('detects when Codex still trusts an older definition of an installed hook', () => {
597
652
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
598
- const codexHome = path.join(cwd, 'codex-home')
653
+ const home = path.join(cwd, 'home')
654
+ const env = { HOME: home }
599
655
  const hookFile = path.join(cwd, '.codex', 'hooks.json')
600
656
  mkdirSync(path.dirname(hookFile), { recursive: true })
601
657
  applyPlan(hookFile, { hooks: ours() })
602
- const installations = findInstallations(cwd, { CODEX_HOME: codexHome })
658
+ const installations = findInstallations(cwd, env)
603
659
  const codex = installations.find((installation) => installation.harness === 'codex')
604
660
  const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
605
661
  expect(stop).toBeDefined()
606
662
 
607
- mkdirSync(codexHome, { recursive: true })
663
+ const trustFile = path.join(home, '.codex', 'config.toml')
664
+ mkdirSync(path.dirname(trustFile), { recursive: true })
608
665
  writeFileSync(
609
- path.join(codexHome, 'config.toml'),
666
+ trustFile,
610
667
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
611
668
  )
612
669
 
613
- const stale = codexTrustProblems(installations, { CODEX_HOME: codexHome })
670
+ const stale = codexTrustProblems(installations, env)
614
671
  expect(stale).toHaveLength(3)
615
672
  expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
616
673
 
617
674
  writeFileSync(
618
- path.join(codexHome, 'config.toml'),
675
+ trustFile,
619
676
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
620
677
  )
621
- expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual([
678
+ expect(codexTrustProblems(installations, env)).toEqual([
622
679
  expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
623
680
  expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
624
681
  ])
625
682
 
626
683
  writeFileSync(
627
- path.join(codexHome, 'config.toml'),
684
+ trustFile,
628
685
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
629
686
  )
630
- expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual(
687
+ expect(codexTrustProblems(installations, env)).toEqual(
631
688
  expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
632
689
  )
633
690
  })
@@ -823,6 +880,14 @@ describe('detectHarness', () => {
823
880
  ])
824
881
  })
825
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
+
826
891
  it('prefers project evidence over anything installed on the machine', () => {
827
892
  // The regression. Machine-global markers used to be OR-ed in per harness,
828
893
  // so a developer with several agent tools installed got a candidate for
@@ -845,3 +910,99 @@ describe('detectHarness', () => {
845
910
  expect(answer === null || HARNESSES.includes(answer)).toBe(true)
846
911
  })
847
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
+ })