@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.
- package/CHANGELOG.md +26 -1
- package/README.md +17 -7
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +43 -38
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.js +2 -1
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +54 -22
- package/dist/install-hooks.js +125 -56
- package/dist/install-hooks.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +3 -3
- package/src/commands.test.ts +144 -34
- package/src/commands.ts +65 -35
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +27 -1
- package/src/hooks.ts +2 -1
- package/src/install-hooks.test.ts +188 -27
- package/src/install-hooks.ts +177 -52
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/ui/config-view.ts +5 -5
package/dist/install-hooks.d.ts
CHANGED
|
@@ -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
|
|
10
|
-
* or
|
|
11
|
-
*
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* `CODEX_HOME
|
|
144
|
-
*
|
|
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
|
|
167
|
-
* matching handler
|
|
168
|
-
*
|
|
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;
|
package/dist/install-hooks.js
CHANGED
|
@@ -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 (`${
|
|
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
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* `CODEX_HOME
|
|
274
|
-
*
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
295
|
-
* matching handler
|
|
296
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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([
|
|
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(
|
|
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 = `${
|
|
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
|
}
|