@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
@@ -1,3 +1,4 @@
1
+ import { type HookHostPlatform } from './hook-adapter.js';
1
2
  import { type Harness } from './harnesses.js';
2
3
  export { HARNESSES, type Harness } from './harnesses.js';
3
4
  /**
@@ -6,9 +7,10 @@ export { HARNESSES, type Harness } from './harnesses.js';
6
7
  * Claude Code's `settings.json` and Codex's hook files use the same shape —
7
8
  * `hooks` maps an event name to matcher groups, each holding command handlers
8
9
  * with an optional timeout — so one generator serves both. Only the file
9
- * location and the event set differ. Codex accepts that shape as `hooks.json`
10
- * or as inline `[hooks]` tables in the same layer's `config.toml`; one
11
- * representation per layer, never both.
10
+ * location and the event set differ. Codex supports either a dedicated
11
+ * `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
12
+ * We default to the dedicated file so installing hooks never rewrites Codex's
13
+ * main configuration and trust store. One representation per layer, never both.
12
14
  *
13
15
  * Cursor's native format is flat and lower-camel-cased, while OpenCode's
14
16
  * extension point is a JavaScript plugin module. Each therefore has a bounded
@@ -34,12 +36,21 @@ export interface InstallPlan {
34
36
  global: boolean;
35
37
  config: HookConfig;
36
38
  }
39
+ export interface HookCommandOptions {
40
+ platform?: NodeJS.Platform | HookHostPlatform;
41
+ /** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
42
+ nodePath?: string;
43
+ }
37
44
  /**
38
45
  * The command each hook runs. Harness definitions know only the stable
39
46
  * user-level adapter. Mutable Node, package-manager, version, and checkout
40
- * paths live behind that seam and never enter a trusted hook identity.
47
+ * paths live behind that seam and never enter a trusted hook identity —
48
+ * except on Windows, where CreateProcess cannot run the adapter without an
49
+ * explicit Node executable, so the registered interpreter is named first.
41
50
  */
42
- export declare function hookCommand(adapterPath: string, event: string, harness?: Harness): string;
51
+ export declare function hookCommand(adapterPath: string, event: string, harness?: Harness, options?: HookCommandOptions): string;
52
+ /** The leading argv that every Notifai handler must start with. */
53
+ export declare function hookCommandPrefix(adapterPath: string, options?: HookCommandOptions): string;
43
54
  /**
44
55
  * Says "Notifai installed this" without saying which checkout did.
45
56
  *
@@ -55,11 +66,21 @@ export declare function hookCommand(adapterPath: string, event: string, harness?
55
66
  * harness runs the command through a shell.
56
67
  */
57
68
  export declare const OWNER_MARKER = "--owner notifai";
69
+ /**
70
+ * Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
71
+ * it as a single argument. Always used for the Node and adapter paths, which
72
+ * routinely contain spaces (`Program Files`) and must not use POSIX quotes
73
+ * inside JSON or TOML harness documents.
74
+ */
75
+ export declare function quoteWindowsArg(value: string): string;
58
76
  export interface BuildOptions {
59
77
  /** Stable user-level executable installed by the hook adapter module. */
60
78
  adapterPath: string;
61
79
  /** The installed adapter stamps its exact harness into project pointers. */
62
80
  harness?: Harness;
81
+ platform?: NodeJS.Platform | HookHostPlatform;
82
+ /** Registered Node named first in Windows hook commands. */
83
+ nodePath?: string;
63
84
  }
64
85
  /**
65
86
  * Claude Code's Stop handler returns instantly and the same process lives on
@@ -105,13 +126,20 @@ export declare function buildCursorHookConfig(options: BuildOptions): CursorHook
105
126
  * gitignored file where one exists: which devices a person wants their
106
127
  * questions pushed to is not a property of the repository.
107
128
  */
108
- export declare function settingsFile(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv): string;
129
+ export declare function settingsFile(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string;
130
+ /**
131
+ * The account home harness config roots are resolved against.
132
+ *
133
+ * Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
134
+ * Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
135
+ */
136
+ export declare function harnessAccountHome(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string;
109
137
  /**
110
138
  * Where Codex looks for a project's hooks — the **main** repository, not the
111
139
  * working directory.
112
140
  *
113
- * Run from a linked git worktree, Codex reads `<main repo>/.codex/hooks.json`
114
- * and never looks at the worktree's own. Proven 2026-08-03 in an isolated
141
+ * Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
142
+ * (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
115
143
  * `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
116
144
  * fired and an identical one at the worktree root did not; removing the main
117
145
  * one left nothing firing at all. Writing to cwd therefore produces a silent
@@ -128,7 +156,7 @@ export declare function codexProjectRoot(cwd: string): string;
128
156
  *
129
157
  * Codex splits the two halves of "which project am I in": it discovers the
130
158
  * project layer by walking up from cwd for a `.codex` directory, but resolves
131
- * `hooks.json` inside that layer against the main repository. In an ordinary
159
+ * the hook file inside that layer against the main repository. In an ordinary
132
160
  * checkout both land on the same directory and the split is invisible. In a
133
161
  * worktree they diverge, and writing only the main repository's file leaves it
134
162
  * unread — Codex never looks, because nothing at or above cwd told it there was
@@ -138,19 +166,21 @@ export declare function codexProjectRoot(cwd: string): string;
138
166
  */
139
167
  export declare function codexLayerDir(cwd: string): string | null;
140
168
  /**
141
- * Both harnesses let an environment variable relocate their whole config
142
- * directory, and tooling that manages sessions does exactly that — Orca points
143
- * `CODEX_HOME` at a per-account home, so writing to `~/.codex` installs hooks
144
- * into a file Codex never reads and the failure is silent. Observed 2026-08-02.
169
+ * Harness-specific home variables relocate the whole active harness home.
170
+ * This matters under session managers that give each Codex account its own
171
+ * `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
172
+ * different Codex installation than the one running the command.
145
173
  */
146
- export declare function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: string): string;
174
+ export declare function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: string, platform?: NodeJS.Platform | HookHostPlatform): string;
175
+ /** User-global Codex config directory for the active Codex installation. */
176
+ export declare function codexGlobalDir(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string;
147
177
  export interface CodexLayerPaths {
148
178
  dir: string;
149
179
  hooksJson: string;
150
180
  configToml: string;
151
181
  }
152
182
  /** The two files Codex will look at for one config layer. */
153
- export declare function codexLayerPaths(global: boolean, cwd: string, env?: NodeJS.ProcessEnv): CodexLayerPaths;
183
+ export declare function codexLayerPaths(global: boolean, cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): CodexLayerPaths;
154
184
  export interface CodexLayerInspection {
155
185
  paths: CodexLayerPaths;
156
186
  jsonEvents: string[];
@@ -163,13 +193,15 @@ export interface CodexLayerInspection {
163
193
  /**
164
194
  * Which representation this Codex layer already uses.
165
195
  *
166
- * Codex loads `hooks.json` and inline `[hooks]` side by side, runs every
167
- * matching handler, and warns when both exist. `[hooks.state]` is the trust
168
- * store, not a hook definition, so it does not count as a representation.
196
+ * Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
197
+ * and runs every matching handler. Prefer the dedicated file for a new layer;
198
+ * preserve an existing single representation so installing Notifai never
199
+ * invents a dual layer. `[hooks.state]` is the trust store, not a hook
200
+ * definition, so it does not count as a representation.
169
201
  */
170
202
  export declare function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection;
171
203
  /** Every file in this layer Codex might already be reading hooks from. */
172
- export declare function hookDefinitionFiles(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv): string[];
204
+ export declare function hookDefinitionFiles(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string[];
173
205
  /**
174
206
  * Drop the unused representation after writing the one this layer already had.
175
207
  *
@@ -186,7 +218,7 @@ export declare function codexRepresentationProblems(cwd: string, env?: NodeJS.Pr
186
218
  *
187
219
  * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
188
220
  */
189
- export declare function detectedHarnesses(cwd: string, env?: NodeJS.ProcessEnv): Harness[];
221
+ export declare function detectedHarnesses(cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): Harness[];
190
222
  /**
191
223
  * Which *single* harness to wire when a caller still needs exactly one, or
192
224
  * null when it is genuinely unclear.
@@ -196,7 +228,7 @@ export declare function detectedHarnesses(cwd: string, env?: NodeJS.ProcessEnv):
196
228
  * are several harnesses. `detectedHarnesses` is the default for install;
197
229
  * this helper stays for uninstall and any caller that cannot take a list.
198
230
  */
199
- export declare function detectHarness(cwd: string, env?: NodeJS.ProcessEnv): Harness | null;
231
+ export declare function detectHarness(cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): Harness | null;
200
232
  export interface SettingsDocument {
201
233
  hooks?: HookConfig;
202
234
  [key: string]: unknown;
@@ -285,6 +317,6 @@ export declare function codexTrustKey(installation: Installation, handler: Insta
285
317
  */
286
318
  export declare function codexTrustProblems(installations: Installation[], env?: NodeJS.ProcessEnv): string[];
287
319
  /** Every place either harness would read a Notifai handler from. */
288
- export declare function findInstallations(cwd: string, env?: NodeJS.ProcessEnv, adapterHome?: string): Installation[];
320
+ export declare function findInstallations(cwd: string, env?: NodeJS.ProcessEnv, adapterHome?: string, platform?: NodeJS.Platform | HookHostPlatform): Installation[];
289
321
  /** The hook event a handler's command actually invokes, e.g. `stop`. */
290
322
  export declare function handlerEvent(command: string): string | null;
@@ -1,12 +1,12 @@
1
1
  import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import os from 'node:os';
4
3
  import path from 'node:path';
5
4
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
5
  import { atomicWriteFileSync } from './atomic-file.js';
7
- import { hookAdapterPath } from './hook-adapter.js';
6
+ import { hookAdapterPath, hookHostPlatform, inspectHookAdapter, } from './hook-adapter.js';
8
7
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js';
9
8
  import { HARNESSES } from './harnesses.js';
9
+ import { accountHome } from './platform.js';
10
10
  export { HARNESSES } from './harnesses.js';
11
11
  /**
12
12
  * The three joints the OpenCode plugin hooks into, as (harness event, the
@@ -21,12 +21,23 @@ const OPENCODE_EVENTS = [
21
21
  /**
22
22
  * The command each hook runs. Harness definitions know only the stable
23
23
  * user-level adapter. Mutable Node, package-manager, version, and checkout
24
- * paths live behind that seam and never enter a trusted hook identity.
24
+ * paths live behind that seam and never enter a trusted hook identity —
25
+ * except on Windows, where CreateProcess cannot run the adapter without an
26
+ * explicit Node executable, so the registered interpreter is named first.
25
27
  */
26
- export function hookCommand(adapterPath, event, harness) {
27
- return (`${quote(adapterPath)} hook ${event} ${OWNER_MARKER}` +
28
+ export function hookCommand(adapterPath, event, harness, options = {}) {
29
+ return (`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
28
30
  (harness === undefined ? '' : ` --harness ${harness}`));
29
31
  }
32
+ /** The leading argv that every Notifai handler must start with. */
33
+ export function hookCommandPrefix(adapterPath, options = {}) {
34
+ const host = hookHostPlatform(options.platform);
35
+ if (host === 'win32') {
36
+ const nodePath = options.nodePath ?? process.execPath;
37
+ return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `;
38
+ }
39
+ return `${quote(adapterPath)} `;
40
+ }
30
41
  /**
31
42
  * Says "Notifai installed this" without saying which checkout did.
32
43
  *
@@ -51,6 +62,30 @@ export const OWNER_MARKER = '--owner notifai';
51
62
  function quote(value) {
52
63
  return `'${value.replaceAll("'", `'\\''`)}'`;
53
64
  }
65
+ /**
66
+ * Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
67
+ * it as a single argument. Always used for the Node and adapter paths, which
68
+ * routinely contain spaces (`Program Files`) and must not use POSIX quotes
69
+ * inside JSON or TOML harness documents.
70
+ */
71
+ export function quoteWindowsArg(value) {
72
+ let out = '"';
73
+ let slashes = 0;
74
+ for (const ch of value) {
75
+ if (ch === '\\') {
76
+ slashes += 1;
77
+ continue;
78
+ }
79
+ if (ch === '"') {
80
+ out += '\\'.repeat(slashes * 2 + 1) + '"';
81
+ slashes = 0;
82
+ continue;
83
+ }
84
+ out += '\\'.repeat(slashes) + ch;
85
+ slashes = 0;
86
+ }
87
+ return `${out}${'\\'.repeat(slashes * 2)}"`;
88
+ }
54
89
  /**
55
90
  * Claude Code's Stop handler returns instantly and the same process lives on
56
91
  * out of band as the escalation waiter, so this `timeout` bounds a background
@@ -86,24 +121,31 @@ export const BLOCKING_STOP_TIMEOUT_SECONDS = 540;
86
121
  * declared ceiling above the wait — except Codex, which owns that number
87
122
  * itself and whose definition must stay byte-stable for its trust store.
88
123
  */
89
- function stopHandler(adapterPath, harness) {
90
- const command = hookCommand(adapterPath, 'stop', harness);
91
- if (harness === 'claude-code') {
124
+ function stopHandler(adapterPath, harness, options) {
125
+ const command = hookCommand(adapterPath, 'stop', harness, options);
126
+ if (harness === 'claude-code' && hookHostPlatform(options.platform) === 'posix') {
92
127
  return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true };
93
128
  }
94
129
  if (harness === 'codex')
95
130
  return { type: 'command', command };
96
131
  return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS };
97
132
  }
133
+ function commandOptionsFrom(options) {
134
+ return {
135
+ ...(options.platform === undefined ? {} : { platform: options.platform }),
136
+ ...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
137
+ };
138
+ }
98
139
  export function buildHookConfig(options) {
99
140
  const { adapterPath } = options;
141
+ const commandOptions = commandOptionsFrom(options);
100
142
  return {
101
143
  UserPromptSubmit: [
102
144
  {
103
145
  hooks: [
104
146
  {
105
147
  type: 'command',
106
- command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
148
+ command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
107
149
  // Claude Code caps UserPromptSubmit at 30s; stay well inside it so
108
150
  // a slow network can never delay the user's own prompt.
109
151
  timeout: 15,
@@ -111,13 +153,13 @@ export function buildHookConfig(options) {
111
153
  ],
112
154
  },
113
155
  ],
114
- Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
156
+ Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
115
157
  SessionEnd: [
116
158
  {
117
159
  hooks: [
118
160
  {
119
161
  type: 'command',
120
- command: hookCommand(adapterPath, 'session-end', options.harness),
162
+ command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
121
163
  // Both harnesses give SessionEnd a ~1-3s budget, so this handler
122
164
  // only touches local state.
123
165
  timeout: 3,
@@ -129,16 +171,17 @@ export function buildHookConfig(options) {
129
171
  }
130
172
  /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
131
173
  export function buildCursorHookConfig(options) {
174
+ const commandOptions = commandOptionsFrom(options);
132
175
  return {
133
176
  beforeSubmitPrompt: [
134
177
  {
135
- command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
178
+ command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor', commandOptions),
136
179
  timeout: 15,
137
180
  },
138
181
  ],
139
182
  stop: [
140
183
  {
141
- command: hookCommand(options.adapterPath, 'stop', 'cursor'),
184
+ command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
142
185
  timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
143
186
  // A continuation may register a real follow-up question. Match the
144
187
  // session-state cap so those chains are useful but never unbounded.
@@ -147,7 +190,7 @@ export function buildCursorHookConfig(options) {
147
190
  ],
148
191
  sessionEnd: [
149
192
  {
150
- command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
193
+ command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
151
194
  timeout: 3,
152
195
  },
153
196
  ],
@@ -158,28 +201,37 @@ export function buildCursorHookConfig(options) {
158
201
  * gitignored file where one exists: which devices a person wants their
159
202
  * questions pushed to is not a property of the repository.
160
203
  */
161
- export function settingsFile(harness, global, cwd, env = process.env) {
204
+ export function settingsFile(harness, global, cwd, env = process.env, platform = process.platform) {
162
205
  switch (harness) {
163
206
  // OpenCode has no settings document to merge into — its adapter is a
164
207
  // generated plugin module, so it owns a whole file instead.
165
208
  case 'opencode':
166
- return opencodePluginPath(global, cwd, env);
209
+ return opencodePluginPath(global, cwd, env, platform);
167
210
  case 'cursor': {
168
- const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir();
211
+ const home = harnessAccountHome(env, platform);
169
212
  return global
170
213
  ? path.join(home, '.cursor', 'hooks.json')
171
214
  : path.join(cwd, '.cursor', 'hooks.json');
172
215
  }
173
216
  case 'claude-code':
174
217
  return global
175
- ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
218
+ ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
176
219
  : path.join(cwd, '.claude', 'settings.local.json');
177
220
  case 'codex':
178
- return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget;
221
+ return inspectCodexLayer(codexLayerPaths(global, cwd, env, platform)).writeTarget;
179
222
  default:
180
223
  return assertNeverHarness(harness);
181
224
  }
182
225
  }
226
+ /**
227
+ * The account home harness config roots are resolved against.
228
+ *
229
+ * Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
230
+ * Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
231
+ */
232
+ export function harnessAccountHome(env = process.env, platform = process.platform) {
233
+ return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux');
234
+ }
183
235
  function assertNeverHarness(harness) {
184
236
  throw new Error(`Unsupported harness: ${String(harness)}`);
185
237
  }
@@ -187,8 +239,8 @@ function assertNeverHarness(harness) {
187
239
  * Where Codex looks for a project's hooks — the **main** repository, not the
188
240
  * working directory.
189
241
  *
190
- * Run from a linked git worktree, Codex reads `<main repo>/.codex/hooks.json`
191
- * and never looks at the worktree's own. Proven 2026-08-03 in an isolated
242
+ * Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
243
+ * (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
192
244
  * `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
193
245
  * fired and an identical one at the worktree root did not; removing the main
194
246
  * one left nothing firing at all. Writing to cwd therefore produces a silent
@@ -220,7 +272,7 @@ export function codexProjectRoot(cwd) {
220
272
  *
221
273
  * Codex splits the two halves of "which project am I in": it discovers the
222
274
  * project layer by walking up from cwd for a `.codex` directory, but resolves
223
- * `hooks.json` inside that layer against the main repository. In an ordinary
275
+ * the hook file inside that layer against the main repository. In an ordinary
224
276
  * checkout both land on the same directory and the split is invisible. In a
225
277
  * worktree they diverge, and writing only the main repository's file leaves it
226
278
  * unread — Codex never looks, because nothing at or above cwd told it there was
@@ -268,19 +320,26 @@ function commonGitDir(gitDir) {
268
320
  return path.resolve(gitDir, readFileSync(marker, 'utf8').trim());
269
321
  }
270
322
  /**
271
- * Both harnesses let an environment variable relocate their whole config
272
- * directory, and tooling that manages sessions does exactly that — Orca points
273
- * `CODEX_HOME` at a per-account home, so writing to `~/.codex` installs hooks
274
- * into a file Codex never reads and the failure is silent. Observed 2026-08-02.
323
+ * Harness-specific home variables relocate the whole active harness home.
324
+ * This matters under session managers that give each Codex account its own
325
+ * `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
326
+ * different Codex installation than the one running the command.
275
327
  */
276
- export function configHome(env, variable, fallback) {
328
+ export function configHome(env, variable, fallback, platform = process.platform) {
277
329
  const override = env[variable];
278
- return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback);
330
+ if (override !== undefined && override !== '')
331
+ return override;
332
+ const home = harnessAccountHome(env, platform);
333
+ return path.join(home, fallback);
334
+ }
335
+ /** User-global Codex config directory for the active Codex installation. */
336
+ export function codexGlobalDir(env = process.env, platform = process.platform) {
337
+ return configHome(env, 'CODEX_HOME', '.codex', platform);
279
338
  }
280
339
  /** The two files Codex will look at for one config layer. */
281
- export function codexLayerPaths(global, cwd, env = process.env) {
340
+ export function codexLayerPaths(global, cwd, env = process.env, platform = process.platform) {
282
341
  const dir = global
283
- ? configHome(env, 'CODEX_HOME', '.codex')
342
+ ? codexGlobalDir(env, platform)
284
343
  : path.join(codexProjectRoot(cwd), '.codex');
285
344
  return {
286
345
  dir,
@@ -291,9 +350,11 @@ export function codexLayerPaths(global, cwd, env = process.env) {
291
350
  /**
292
351
  * Which representation this Codex layer already uses.
293
352
  *
294
- * Codex loads `hooks.json` and inline `[hooks]` side by side, runs every
295
- * matching handler, and warns when both exist. `[hooks.state]` is the trust
296
- * store, not a hook definition, so it does not count as a representation.
353
+ * Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
354
+ * and runs every matching handler. Prefer the dedicated file for a new layer;
355
+ * preserve an existing single representation so installing Notifai never
356
+ * invents a dual layer. `[hooks.state]` is the trust store, not a hook
357
+ * definition, so it does not count as a representation.
297
358
  */
298
359
  export function inspectCodexLayer(paths) {
299
360
  const jsonDocument = tryLoadSettings(paths.hooksJson);
@@ -306,10 +367,9 @@ export function inspectCodexLayer(paths) {
306
367
  const tomlOurs = documentHasOurHandlers(tomlDocument);
307
368
  const dual = jsonEvents.length > 0 && tomlEvents.length > 0;
308
369
  let writeTarget = paths.hooksJson;
309
- if (tomlEvents.length > 0 && jsonEvents.length === 0)
370
+ if (tomlEvents.length > 0 && jsonEvents.length === 0) {
310
371
  writeTarget = paths.configToml;
311
- else if (jsonEvents.length > 0 && tomlEvents.length === 0)
312
- writeTarget = paths.hooksJson;
372
+ }
313
373
  else if (dual) {
314
374
  if (tomlForeign && !jsonForeign)
315
375
  writeTarget = paths.configToml;
@@ -319,8 +379,6 @@ export function inspectCodexLayer(paths) {
319
379
  writeTarget = paths.hooksJson;
320
380
  else if (tomlOurs && !jsonOurs)
321
381
  writeTarget = paths.configToml;
322
- else if (jsonOurs && !tomlOurs)
323
- writeTarget = paths.hooksJson;
324
382
  else
325
383
  writeTarget = paths.hooksJson;
326
384
  }
@@ -335,10 +393,10 @@ export function inspectCodexLayer(paths) {
335
393
  };
336
394
  }
337
395
  /** Every file in this layer Codex might already be reading hooks from. */
338
- export function hookDefinitionFiles(harness, global, cwd, env = process.env) {
396
+ export function hookDefinitionFiles(harness, global, cwd, env = process.env, platform = process.platform) {
339
397
  if (harness !== 'codex')
340
- return [settingsFile(harness, global, cwd, env)];
341
- const paths = codexLayerPaths(global, cwd, env);
398
+ return [settingsFile(harness, global, cwd, env, platform)];
399
+ const paths = codexLayerPaths(global, cwd, env, platform);
342
400
  return [paths.hooksJson, paths.configToml];
343
401
  }
344
402
  /**
@@ -432,12 +490,12 @@ function localHarnessEvidence(cwd) {
432
490
  return found;
433
491
  }
434
492
  /** Harnesses installed anywhere on this machine — a much weaker signal. */
435
- function globalHarnessEvidence(env = process.env) {
436
- const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir();
493
+ function globalHarnessEvidence(env = process.env, platform = process.platform) {
494
+ const home = harnessAccountHome(env, platform);
437
495
  const found = [];
438
496
  if (existsSync(path.join(home, '.claude')))
439
497
  found.push('claude-code');
440
- if (existsSync(path.join(home, '.codex')))
498
+ if (existsSync(codexGlobalDir(env, platform)))
441
499
  found.push('codex');
442
500
  if (existsSync(path.join(home, '.cursor')))
443
501
  found.push('cursor');
@@ -452,8 +510,11 @@ function globalHarnessEvidence(env = process.env) {
452
510
  *
453
511
  * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
454
512
  */
455
- export function detectedHarnesses(cwd, env = process.env) {
456
- const seen = new Set([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)]);
513
+ export function detectedHarnesses(cwd, env = process.env, platform = process.platform) {
514
+ const seen = new Set([
515
+ ...localHarnessEvidence(cwd),
516
+ ...globalHarnessEvidence(env, platform),
517
+ ]);
457
518
  return HARNESSES.filter((harness) => seen.has(harness));
458
519
  }
459
520
  /**
@@ -465,11 +526,11 @@ export function detectedHarnesses(cwd, env = process.env) {
465
526
  * are several harnesses. `detectedHarnesses` is the default for install;
466
527
  * this helper stays for uninstall and any caller that cannot take a list.
467
528
  */
468
- export function detectHarness(cwd, env = process.env) {
529
+ export function detectHarness(cwd, env = process.env, platform = process.platform) {
469
530
  const local = localHarnessEvidence(cwd);
470
531
  if (local.length > 0)
471
532
  return local.length === 1 ? local[0] : null;
472
- const global = globalHarnessEvidence(env);
533
+ const global = globalHarnessEvidence(env, platform);
473
534
  return global.length === 1 ? global[0] : null;
474
535
  }
475
536
  function readCursorSettings(file) {
@@ -703,7 +764,7 @@ export function codexHookIdentityHash(handler) {
703
764
  return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`;
704
765
  }
705
766
  function codexTrustState(env) {
706
- const file = path.join(configHome(env, 'CODEX_HOME', '.codex'), 'config.toml');
767
+ const file = path.join(codexGlobalDir(env), 'config.toml');
707
768
  if (!existsSync(file))
708
769
  return {};
709
770
  try {
@@ -773,11 +834,16 @@ function isNotifaiCommand(command) {
773
834
  return / hook (user-prompt-submit|stop|session-end)\b/.test(command);
774
835
  }
775
836
  /** Every place either harness would read a Notifai handler from. */
776
- export function findInstallations(cwd, env = process.env, adapterHome) {
837
+ export function findInstallations(cwd, env = process.env, adapterHome, platform = process.platform) {
838
+ const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath;
839
+ const commandOptions = {
840
+ platform,
841
+ ...(nodePath === undefined ? {} : { nodePath }),
842
+ };
777
843
  const found = [];
778
844
  for (const harness of HARNESSES) {
779
845
  for (const global of [false, true]) {
780
- const files = hookDefinitionFiles(harness, global, cwd, env);
846
+ const files = hookDefinitionFiles(harness, global, cwd, env, platform);
781
847
  for (const file of files) {
782
848
  if (!existsSync(file))
783
849
  continue;
@@ -817,7 +883,10 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
817
883
  event,
818
884
  groupIndex: 0,
819
885
  handlerIndex: 0,
820
- command: hookCommand(target.adapter, hookEvent, 'opencode'),
886
+ command: hookCommand(target.adapter, hookEvent, 'opencode', {
887
+ ...commandOptions,
888
+ ...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
889
+ }),
821
890
  ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
822
891
  })),
823
892
  });
@@ -833,7 +902,7 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
833
902
  }
834
903
  const handlers = locateCursorHandlers(document);
835
904
  if (handlers.length > 0) {
836
- const problems = harnessMarkerProblems(harness, handlers, adapterHome);
905
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
837
906
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
838
907
  }
839
908
  continue;
@@ -847,7 +916,7 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
847
916
  }
848
917
  const handlers = locateHandlers(document);
849
918
  if (handlers.length > 0) {
850
- const problems = harnessMarkerProblems(harness, handlers, adapterHome);
919
+ const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
851
920
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
852
921
  }
853
922
  }
@@ -855,12 +924,12 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
855
924
  }
856
925
  return found;
857
926
  }
858
- function harnessMarkerProblems(harness, handlers, adapterHome) {
927
+ function harnessMarkerProblems(harness, handlers, adapterHome, options = {}) {
859
928
  const problems = [];
860
929
  if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
861
930
  problems.push(`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``);
862
931
  }
863
- const expected = `${quote(hookAdapterPath(adapterHome))} hook `;
932
+ const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `;
864
933
  if (!handlers.every((handler) => handler.command.startsWith(expected))) {
865
934
  problems.push('installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter');
866
935
  }