@raidiant/notifai 10.1.1 → 10.1.3
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 +35 -0
- package/dist/commands-core.d.ts +4 -1
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +2 -7
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.d.ts +12 -0
- package/dist/commands-hooks.js +15 -1
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +9 -6
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +10 -11
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/harness-session-title.d.ts +22 -0
- package/dist/harness-session-title.js +74 -0
- package/dist/harness-session-title.js.map +1 -0
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +15 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +2 -1
- package/dist/node-floor.js +9 -5
- package/dist/node-floor.js.map +1 -1
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/session-labels.d.ts +2 -0
- package/dist/session-labels.js +20 -9
- package/dist/session-labels.js.map +1 -1
- package/dist/setup-destinations.js +1 -1
- package/dist/setup-destinations.js.map +1 -1
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -176
- package/src/cli-bin.ts +0 -233
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/cli-update-contract.ts +0 -9
- package/src/cli-update.test.ts +0 -125
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -352
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -301
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -238
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -312
- package/src/commands-devices.ts +0 -180
- package/src/commands-doctor.ts +0 -1501
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2318
- package/src/commands-init.ts +0 -1055
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -875
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands-update.ts +0 -180
- package/src/commands.test.ts +0 -8297
- package/src/commands.ts +0 -77
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -424
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5824
- package/src/hooks.ts +0 -4011
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main-run.ts +0 -45
- package/src/main.ts +0 -12
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/node-floor.test.ts +0 -27
- package/src/node-floor.ts +0 -37
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -317
- package/src/program.ts +0 -842
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-settlement-process.ts +0 -47
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -212
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/setup-destinations.test.ts +0 -41
- package/src/setup-destinations.ts +0 -50
- package/src/skill.test.ts +0 -426
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/install-hooks.ts
DELETED
|
@@ -1,1501 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
lstatSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
readdirSync,
|
|
6
|
-
realpathSync,
|
|
7
|
-
rmdirSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
statSync,
|
|
10
|
-
} from 'node:fs'
|
|
11
|
-
import { createHash } from 'node:crypto'
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
14
|
-
import { atomicWriteFileSync } from './atomic-file.js'
|
|
15
|
-
import { withTargetFileLock } from './file-lock.js'
|
|
16
|
-
import {
|
|
17
|
-
hookAdapterPath,
|
|
18
|
-
hookHostPlatform,
|
|
19
|
-
inspectHookAdapter,
|
|
20
|
-
type HookHostPlatform,
|
|
21
|
-
} from './hook-adapter.js'
|
|
22
|
-
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
23
|
-
import {
|
|
24
|
-
OPENCLAW_EVENTS,
|
|
25
|
-
openclawHasGlobalEvidence,
|
|
26
|
-
openclawPluginPath,
|
|
27
|
-
openclawPluginTarget,
|
|
28
|
-
} from './openclaw-plugin.js'
|
|
29
|
-
import { HOOK_INSTALLABLE_HARNESSES, type HookInstallableHarness } from './harnesses.js'
|
|
30
|
-
import { accountHome } from './platform.js'
|
|
31
|
-
import {
|
|
32
|
-
NON_ROUTING_STOP_TIMEOUT_SECONDS,
|
|
33
|
-
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
34
|
-
} from './question-timing.js'
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
38
|
-
* `notifai hook` event it runs). The plugin is a module and has no settings
|
|
39
|
-
* entries to read, so `findInstallations` reconstructs its handlers from this.
|
|
40
|
-
*/
|
|
41
|
-
const OPENCODE_EVENTS = [
|
|
42
|
-
['UserPromptSubmit', 'user-prompt-submit'],
|
|
43
|
-
['Stop', 'stop'],
|
|
44
|
-
['SessionEnd', 'session-end'],
|
|
45
|
-
] as const
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Harness hook installation.
|
|
49
|
-
*
|
|
50
|
-
* Claude Code's `settings.json` and Codex's hook files use the same shape —
|
|
51
|
-
* `hooks` maps an event name to matcher groups, each holding command handlers
|
|
52
|
-
* with an optional timeout — so one generator serves both. Only the file
|
|
53
|
-
* location and the event set differ. Codex supports either a dedicated
|
|
54
|
-
* `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
|
|
55
|
-
* We default a hook-empty layer to inline config and preserve whichever single
|
|
56
|
-
* representation a populated layer already uses. Notifai lives in one of them,
|
|
57
|
-
* never both.
|
|
58
|
-
*
|
|
59
|
-
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
60
|
-
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
61
|
-
* adapter instead of being forced through this shared document shape.
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
export interface HookHandler {
|
|
65
|
-
type: 'command'
|
|
66
|
-
command: string
|
|
67
|
-
timeout?: number
|
|
68
|
-
async?: boolean
|
|
69
|
-
statusMessage?: string
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface HookGroup {
|
|
73
|
-
matcher?: string
|
|
74
|
-
hooks: HookHandler[]
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export type HookConfig = Record<string, HookGroup[]>
|
|
78
|
-
|
|
79
|
-
export interface InstallPlan {
|
|
80
|
-
harness: HookInstallableHarness
|
|
81
|
-
/** Absolute path of the settings file that will be written. */
|
|
82
|
-
file: string
|
|
83
|
-
/** True when the file is machine-wide rather than per-project. */
|
|
84
|
-
global: boolean
|
|
85
|
-
config: HookConfig
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface HookCommandOptions {
|
|
89
|
-
platform?: NodeJS.Platform | HookHostPlatform
|
|
90
|
-
/** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
|
|
91
|
-
nodePath?: string
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The command each hook runs. Harness definitions know only the stable
|
|
96
|
-
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
97
|
-
* paths live behind that seam and never enter a trusted hook identity —
|
|
98
|
-
* except on Windows, where CreateProcess cannot run the adapter without an
|
|
99
|
-
* explicit Node executable, so the registered interpreter is named first.
|
|
100
|
-
*/
|
|
101
|
-
export function hookCommand(
|
|
102
|
-
adapterPath: string,
|
|
103
|
-
event: string,
|
|
104
|
-
harness?: HookInstallableHarness,
|
|
105
|
-
options: HookCommandOptions = {},
|
|
106
|
-
): string {
|
|
107
|
-
return (
|
|
108
|
-
`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
|
|
109
|
-
(harness === undefined ? '' : ` --harness ${harness}`)
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** The leading argv that every Notifai handler must start with. */
|
|
114
|
-
export function hookCommandPrefix(
|
|
115
|
-
adapterPath: string,
|
|
116
|
-
options: HookCommandOptions = {},
|
|
117
|
-
): string {
|
|
118
|
-
const host = hookHostPlatform(options.platform)
|
|
119
|
-
if (host === 'win32') {
|
|
120
|
-
const nodePath = options.nodePath ?? process.execPath
|
|
121
|
-
return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `
|
|
122
|
-
}
|
|
123
|
-
return `${quote(adapterPath)} `
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Says "Notifai installed this" without saying which checkout did.
|
|
128
|
-
*
|
|
129
|
-
* Ownership used to be matched on the absolute script path, so installing from
|
|
130
|
-
* a second checkout did not recognise the first one's handlers as ours. Both
|
|
131
|
-
* stayed, the harness ran both, and one question produced two notifications;
|
|
132
|
-
* uninstalling the second silently left the first running. A marker that every
|
|
133
|
-
* Notifai build writes and no build's path appears in fixes that by making
|
|
134
|
-
* ownership a property of the handler rather than of the machine it was
|
|
135
|
-
* installed from.
|
|
136
|
-
*
|
|
137
|
-
* It is a real (ignored) CLI flag rather than a comment because not every
|
|
138
|
-
* harness runs the command through a shell.
|
|
139
|
-
*/
|
|
140
|
-
export const OWNER_MARKER = '--owner notifai'
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* POSIX single-quoting. Double quotes were wrong: they still expand `$(...)`
|
|
144
|
-
* and backticks, so a checkout path containing shell syntax became command
|
|
145
|
-
* execution on every hook event. Single quotes suppress all expansion; the
|
|
146
|
-
* only escape needed is for a literal single quote.
|
|
147
|
-
*/
|
|
148
|
-
function quote(value: string): string {
|
|
149
|
-
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
|
|
154
|
-
* it as a single argument. Always used for the Node and adapter paths, which
|
|
155
|
-
* routinely contain spaces (`Program Files`) and must not use POSIX quotes
|
|
156
|
-
* inside JSON or TOML harness documents.
|
|
157
|
-
*/
|
|
158
|
-
export function quoteWindowsArg(value: string): string {
|
|
159
|
-
let out = '"'
|
|
160
|
-
let slashes = 0
|
|
161
|
-
for (const ch of value) {
|
|
162
|
-
if (ch === '\\') {
|
|
163
|
-
slashes += 1
|
|
164
|
-
continue
|
|
165
|
-
}
|
|
166
|
-
if (ch === '"') {
|
|
167
|
-
out += '\\'.repeat(slashes * 2 + 1) + '"'
|
|
168
|
-
slashes = 0
|
|
169
|
-
continue
|
|
170
|
-
}
|
|
171
|
-
out += '\\'.repeat(slashes) + ch
|
|
172
|
-
slashes = 0
|
|
173
|
-
}
|
|
174
|
-
return `${out}${'\\'.repeat(slashes * 2)}"`
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export interface BuildOptions {
|
|
178
|
-
/** Stable user-level executable installed by the hook adapter module. */
|
|
179
|
-
adapterPath: string
|
|
180
|
-
/** The installed adapter stamps its exact harness into project pointers. */
|
|
181
|
-
harness?: HookInstallableHarness
|
|
182
|
-
platform?: NodeJS.Platform | HookHostPlatform
|
|
183
|
-
/** Registered Node named first in Windows hook commands. */
|
|
184
|
-
nodePath?: string
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Harnesses without asynchronous Question Routing use this short Stop budget.
|
|
189
|
-
* Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
|
|
190
|
-
*/
|
|
191
|
-
export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS
|
|
192
|
-
export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Whether this harness's Stop handler runs detached from the turn.
|
|
196
|
-
*
|
|
197
|
-
* The installer and doctor share this predicate so they agree whether Claude
|
|
198
|
-
* runs out of band. Owner lifetime is route-neutral and lives in
|
|
199
|
-
* `question-timing.ts`.
|
|
200
|
-
*/
|
|
201
|
-
export function stopHandlerIsDetached(
|
|
202
|
-
harness: HookInstallableHarness | undefined,
|
|
203
|
-
platform?: Parameters<typeof hookHostPlatform>[0],
|
|
204
|
-
): boolean {
|
|
205
|
-
return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* The turn-end handler, whose shape is the whole per-harness difference.
|
|
210
|
-
*
|
|
211
|
-
* Claude Code takes the answer over its own inbox socket, so its Stop hook is
|
|
212
|
-
* `async: true`: it returns immediately, the terminal is never held, and the
|
|
213
|
-
* waiter finishes out of band. Codex and Claude on Windows block and print a
|
|
214
|
-
* continuation to stdout. Every Question Routing owner declares the same
|
|
215
|
-
* complete-window timeout; changing Codex's definition requires one explicit
|
|
216
|
-
* trust approval.
|
|
217
|
-
*/
|
|
218
|
-
function stopHandler(
|
|
219
|
-
adapterPath: string,
|
|
220
|
-
harness: HookInstallableHarness | undefined,
|
|
221
|
-
options: HookCommandOptions,
|
|
222
|
-
): HookHandler {
|
|
223
|
-
const command = hookCommand(adapterPath, 'stop', harness, options)
|
|
224
|
-
if (stopHandlerIsDetached(harness, options.platform)) {
|
|
225
|
-
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true }
|
|
226
|
-
}
|
|
227
|
-
if (harness === 'codex' || harness === 'claude-code') {
|
|
228
|
-
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS }
|
|
229
|
-
}
|
|
230
|
-
return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
|
|
234
|
-
return {
|
|
235
|
-
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
236
|
-
...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
241
|
-
const { adapterPath } = options
|
|
242
|
-
const commandOptions = commandOptionsFrom(options)
|
|
243
|
-
return {
|
|
244
|
-
SessionStart: [
|
|
245
|
-
{
|
|
246
|
-
hooks: [
|
|
247
|
-
{
|
|
248
|
-
type: 'command',
|
|
249
|
-
command: hookCommand(adapterPath, 'session-start', options.harness, commandOptions),
|
|
250
|
-
// Activation is local context only: no config, credentials, or network.
|
|
251
|
-
timeout: 5,
|
|
252
|
-
},
|
|
253
|
-
],
|
|
254
|
-
},
|
|
255
|
-
],
|
|
256
|
-
SubagentStart: [
|
|
257
|
-
{
|
|
258
|
-
hooks: [
|
|
259
|
-
{
|
|
260
|
-
type: 'command',
|
|
261
|
-
command: hookCommand(adapterPath, 'subagent-start', options.harness, commandOptions),
|
|
262
|
-
timeout: 5,
|
|
263
|
-
},
|
|
264
|
-
],
|
|
265
|
-
},
|
|
266
|
-
],
|
|
267
|
-
UserPromptSubmit: [
|
|
268
|
-
{
|
|
269
|
-
hooks: [
|
|
270
|
-
{
|
|
271
|
-
type: 'command',
|
|
272
|
-
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
|
|
273
|
-
// Claude Code defaults UserPromptSubmit to 30s. Choose a shorter
|
|
274
|
-
// explicit budget so a slow network cannot delay the user's prompt.
|
|
275
|
-
timeout: 15,
|
|
276
|
-
},
|
|
277
|
-
],
|
|
278
|
-
},
|
|
279
|
-
],
|
|
280
|
-
Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
|
|
281
|
-
SessionEnd: [
|
|
282
|
-
{
|
|
283
|
-
hooks: [
|
|
284
|
-
{
|
|
285
|
-
type: 'command',
|
|
286
|
-
command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
|
|
287
|
-
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
288
|
-
// only touches local state.
|
|
289
|
-
timeout: 3,
|
|
290
|
-
},
|
|
291
|
-
],
|
|
292
|
-
},
|
|
293
|
-
],
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export interface CursorHookHandler {
|
|
298
|
-
command: string
|
|
299
|
-
timeout?: number
|
|
300
|
-
loop_limit?: number | null
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export type CursorHookConfig = Record<string, CursorHookHandler[]>
|
|
304
|
-
|
|
305
|
-
interface CursorSettingsDocument {
|
|
306
|
-
version?: number
|
|
307
|
-
hooks?: CursorHookConfig
|
|
308
|
-
[key: string]: unknown
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
312
|
-
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
313
|
-
const commandOptions = commandOptionsFrom(options)
|
|
314
|
-
return {
|
|
315
|
-
sessionStart: [
|
|
316
|
-
{
|
|
317
|
-
command: hookCommand(options.adapterPath, 'session-start', 'cursor', commandOptions),
|
|
318
|
-
// Activation only emits local context and must not depend on setup.
|
|
319
|
-
timeout: 5,
|
|
320
|
-
},
|
|
321
|
-
],
|
|
322
|
-
beforeSubmitPrompt: [
|
|
323
|
-
{
|
|
324
|
-
command: hookCommand(
|
|
325
|
-
options.adapterPath,
|
|
326
|
-
'user-prompt-submit',
|
|
327
|
-
'cursor',
|
|
328
|
-
commandOptions,
|
|
329
|
-
),
|
|
330
|
-
timeout: 15,
|
|
331
|
-
},
|
|
332
|
-
],
|
|
333
|
-
stop: [
|
|
334
|
-
{
|
|
335
|
-
command: hookCommand(options.adapterPath, 'activation-stop', 'cursor', commandOptions),
|
|
336
|
-
timeout: 5,
|
|
337
|
-
loop_limit: 1,
|
|
338
|
-
},
|
|
339
|
-
{
|
|
340
|
-
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
341
|
-
timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
342
|
-
// A continuation may register a real follow-up question. Match the
|
|
343
|
-
// session-state cap so those chains are useful but never unbounded.
|
|
344
|
-
loop_limit: 3,
|
|
345
|
-
},
|
|
346
|
-
],
|
|
347
|
-
sessionEnd: [
|
|
348
|
-
{
|
|
349
|
-
command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
|
|
350
|
-
timeout: 3,
|
|
351
|
-
},
|
|
352
|
-
],
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Where each harness reads hooks from. Project installs deliberately target the
|
|
358
|
-
* gitignored file where one exists: which devices a person wants their
|
|
359
|
-
* questions pushed to is not a property of the repository.
|
|
360
|
-
*/
|
|
361
|
-
export function settingsFile(
|
|
362
|
-
harness: HookInstallableHarness,
|
|
363
|
-
global: boolean,
|
|
364
|
-
cwd: string,
|
|
365
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
366
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
367
|
-
): string {
|
|
368
|
-
switch (harness) {
|
|
369
|
-
// OpenCode has no settings document to merge into — its adapter is a
|
|
370
|
-
// generated plugin module, so it owns a whole file instead.
|
|
371
|
-
case 'opencode':
|
|
372
|
-
return opencodePluginPath(global, cwd, env, platform)
|
|
373
|
-
case 'openclaw':
|
|
374
|
-
return openclawPluginPath(global, cwd, env, platform)
|
|
375
|
-
case 'cursor': {
|
|
376
|
-
const home = harnessAccountHome(env, platform)
|
|
377
|
-
return global
|
|
378
|
-
? path.join(home, '.cursor', 'hooks.json')
|
|
379
|
-
: path.join(cwd, '.cursor', 'hooks.json')
|
|
380
|
-
}
|
|
381
|
-
case 'claude-code':
|
|
382
|
-
return global
|
|
383
|
-
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
|
|
384
|
-
: path.join(cwd, '.claude', 'settings.local.json')
|
|
385
|
-
case 'codex':
|
|
386
|
-
return inspectCodexLayer(codexLayerPaths(global, cwd, env, platform)).writeTarget
|
|
387
|
-
default:
|
|
388
|
-
return assertNeverHarness(harness)
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* The account home harness config roots are resolved against.
|
|
394
|
-
*
|
|
395
|
-
* Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
|
|
396
|
-
* Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
|
|
397
|
-
*/
|
|
398
|
-
export function harnessAccountHome(
|
|
399
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
400
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
401
|
-
): string {
|
|
402
|
-
return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux')
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function assertNeverHarness(harness: never): never {
|
|
406
|
-
throw new Error(`Unsupported harness: ${String(harness)}`)
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Where Codex looks for a project's hooks — the **main** repository, not the
|
|
411
|
-
* working directory.
|
|
412
|
-
*
|
|
413
|
-
* Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
|
|
414
|
-
* (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
415
|
-
* `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
|
|
416
|
-
* fired and an identical one at the worktree root did not; removing the main
|
|
417
|
-
* one left nothing firing at all. Writing to cwd therefore produces a silent
|
|
418
|
-
* no-op for anyone whose agent runs in a worktree, which is the normal case
|
|
419
|
-
* under tooling like Orca.
|
|
420
|
-
*
|
|
421
|
-
* Claude Code does not share this behaviour — it reads the worktree's own
|
|
422
|
-
* `.claude/settings.local.json` — so this deliberately applies to Codex only.
|
|
423
|
-
*/
|
|
424
|
-
export function codexProjectRoot(cwd: string): string {
|
|
425
|
-
const entry = findGitEntry(cwd)
|
|
426
|
-
if (entry === null) return cwd
|
|
427
|
-
try {
|
|
428
|
-
if (statSync(entry).isDirectory()) return path.dirname(entry)
|
|
429
|
-
const linked = worktreeGitDir(entry)
|
|
430
|
-
return linked === null ? cwd : path.dirname(commonGitDir(linked))
|
|
431
|
-
} catch {
|
|
432
|
-
// An unreadable or exotic checkout is not worth failing an install over;
|
|
433
|
-
// cwd is what we used to do and is right for every non-worktree case.
|
|
434
|
-
return cwd
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* The `.codex` directory that has to exist for Codex to load project hooks at
|
|
440
|
-
* all, or null when nothing extra is needed.
|
|
441
|
-
*
|
|
442
|
-
* Codex splits the two halves of "which project am I in": it discovers the
|
|
443
|
-
* project layer by walking up from cwd for a `.codex` directory, but resolves
|
|
444
|
-
* the hook file inside that layer against the main repository. In an ordinary
|
|
445
|
-
* checkout both land on the same directory and the split is invisible. In a
|
|
446
|
-
* worktree they diverge, and writing only the main repository's file leaves it
|
|
447
|
-
* unread — Codex never looks, because nothing at or above cwd told it there was
|
|
448
|
-
* a project layer to load. Proven 2026-08-03: with cwd in a worktree, an *empty*
|
|
449
|
-
* `.codex` directory there was the difference between the main repository's
|
|
450
|
-
* handler running and nothing running.
|
|
451
|
-
*/
|
|
452
|
-
export function codexLayerDir(cwd: string): string | null {
|
|
453
|
-
const entry = findGitEntry(cwd)
|
|
454
|
-
if (entry === null) return null
|
|
455
|
-
const worktreeRoot = path.dirname(entry)
|
|
456
|
-
if (path.resolve(worktreeRoot) === path.resolve(codexProjectRoot(cwd))) return null
|
|
457
|
-
return path.join(worktreeRoot, '.codex')
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** The nearest `.git` at or above `from`, or null outside a repository. */
|
|
461
|
-
function findGitEntry(from: string): string | null {
|
|
462
|
-
let dir = path.resolve(from)
|
|
463
|
-
for (;;) {
|
|
464
|
-
const entry = path.join(dir, '.git')
|
|
465
|
-
if (existsSync(entry)) return entry
|
|
466
|
-
const parent = path.dirname(dir)
|
|
467
|
-
if (parent === dir) return null
|
|
468
|
-
dir = parent
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/** `.git` in a linked worktree is a file naming that worktree's git directory. */
|
|
473
|
-
function worktreeGitDir(file: string): string | null {
|
|
474
|
-
const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(file, 'utf8'))
|
|
475
|
-
if (match === null) return null
|
|
476
|
-
return path.resolve(path.dirname(file), match[1]!.trim())
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* A worktree's git directory records the shared one in `commondir`; that shared
|
|
481
|
-
* directory is the main repository's `.git`, whose parent is its root.
|
|
482
|
-
*/
|
|
483
|
-
function commonGitDir(gitDir: string): string {
|
|
484
|
-
const marker = path.join(gitDir, 'commondir')
|
|
485
|
-
if (!existsSync(marker)) return gitDir
|
|
486
|
-
return path.resolve(gitDir, readFileSync(marker, 'utf8').trim())
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Harness-specific home variables relocate the whole active harness home.
|
|
491
|
-
* This matters under session managers that give each Codex account its own
|
|
492
|
-
* `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
|
|
493
|
-
* different Codex installation than the one running the command.
|
|
494
|
-
*/
|
|
495
|
-
export function configHome(
|
|
496
|
-
env: NodeJS.ProcessEnv,
|
|
497
|
-
variable: string,
|
|
498
|
-
fallback: string,
|
|
499
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
500
|
-
): string {
|
|
501
|
-
const override = env[variable]
|
|
502
|
-
if (override !== undefined && override !== '') return override
|
|
503
|
-
const home = harnessAccountHome(env, platform)
|
|
504
|
-
return path.join(home, fallback)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* User-global Codex config directory for the *running* Codex installation.
|
|
509
|
-
*
|
|
510
|
-
* `CODEX_HOME` replaces the home; it does not shadow it. `codex doctor` with
|
|
511
|
-
* the variable set reports that directory for every path it resolves and never
|
|
512
|
-
* consults `~/.codex` at all. So a session manager that points Codex at its own
|
|
513
|
-
* home makes `~/.codex` inert for those sessions, and installing there would
|
|
514
|
-
* write hooks the running agent never reads while reporting success.
|
|
515
|
-
*
|
|
516
|
-
* Following the variable is therefore correct even though it means install and
|
|
517
|
-
* doctor resolve different homes when run from different shells. Doctor names
|
|
518
|
-
* the home it inspected for exactly that reason — see `codexHomeNote`.
|
|
519
|
-
*/
|
|
520
|
-
export function codexGlobalDir(
|
|
521
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
522
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
523
|
-
): string {
|
|
524
|
-
return configHome(env, 'CODEX_HOME', '.codex', platform)
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
export interface CodexLayerPaths {
|
|
528
|
-
dir: string
|
|
529
|
-
hooksJson: string
|
|
530
|
-
configToml: string
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/** The two files Codex will look at for one config layer. */
|
|
534
|
-
export function codexLayerPaths(
|
|
535
|
-
global: boolean,
|
|
536
|
-
cwd: string,
|
|
537
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
538
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
539
|
-
): CodexLayerPaths {
|
|
540
|
-
const dir = global
|
|
541
|
-
? codexGlobalDir(env, platform)
|
|
542
|
-
: path.join(codexProjectRoot(cwd), '.codex')
|
|
543
|
-
return {
|
|
544
|
-
dir,
|
|
545
|
-
hooksJson: path.join(dir, 'hooks.json'),
|
|
546
|
-
configToml: path.join(dir, 'config.toml'),
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
export interface CodexLayerInspection {
|
|
551
|
-
paths: CodexLayerPaths
|
|
552
|
-
/** Every event this file defines a handler for, whoever owns it. */
|
|
553
|
-
jsonEvents: string[]
|
|
554
|
-
tomlEvents: string[]
|
|
555
|
-
/** The subset of those events whose handlers are Notifai's own. */
|
|
556
|
-
ourJsonEvents: string[]
|
|
557
|
-
ourTomlEvents: string[]
|
|
558
|
-
writeTarget: string
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Where Notifai writes in this Codex layer: wherever Notifai already is, then
|
|
563
|
-
* whichever single hook representation the layer already uses, and inline
|
|
564
|
-
* `config.toml` for a hook-empty or already-mixed layer.
|
|
565
|
-
*
|
|
566
|
-
* Codex supports `hooks.json` and inline `[hooks]` side by side, loads both,
|
|
567
|
-
* and runs every matching handler. Reusing the one populated representation is
|
|
568
|
-
* the generic layer contract: it avoids creating Codex's dual-representation
|
|
569
|
-
* warning and, for a managed layer, writes through the same authority that
|
|
570
|
-
* persists the layer rather than a sibling file it may regenerate away.
|
|
571
|
-
*
|
|
572
|
-
* Staying put where Notifai already is also keeps reinstalls idempotent and
|
|
573
|
-
* preserves the `[hooks.state]` trust hash, which Codex keys by file path and
|
|
574
|
-
* handler index. `[hooks.state]` is the trust store, not a hook definition, so
|
|
575
|
-
* it does not count as a representation.
|
|
576
|
-
*/
|
|
577
|
-
export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
|
|
578
|
-
const jsonDocument = tryLoadSettings(paths.hooksJson)
|
|
579
|
-
const tomlDocument = tryLoadSettings(paths.configToml)
|
|
580
|
-
const jsonEvents = hookEventNames(jsonDocument?.hooks)
|
|
581
|
-
const tomlEvents = hookEventNames(tomlDocument?.hooks)
|
|
582
|
-
const ourJsonEvents = ourHandlerEvents(jsonDocument)
|
|
583
|
-
const ourTomlEvents = ourHandlerEvents(tomlDocument)
|
|
584
|
-
const writeTarget =
|
|
585
|
-
ourJsonEvents.length > 0 && ourTomlEvents.length === 0
|
|
586
|
-
? paths.hooksJson
|
|
587
|
-
: ourTomlEvents.length > 0
|
|
588
|
-
? paths.configToml
|
|
589
|
-
: jsonEvents.length > 0 && tomlEvents.length === 0
|
|
590
|
-
? paths.hooksJson
|
|
591
|
-
: paths.configToml
|
|
592
|
-
return {
|
|
593
|
-
paths,
|
|
594
|
-
jsonEvents,
|
|
595
|
-
tomlEvents,
|
|
596
|
-
ourJsonEvents,
|
|
597
|
-
ourTomlEvents,
|
|
598
|
-
writeTarget,
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Serialize one Notifai transaction across both representations in a Codex
|
|
604
|
-
* layer, then inspect that layer only after acquiring the shared anchor.
|
|
605
|
-
*/
|
|
606
|
-
export function withCodexLayerTransaction<T>(
|
|
607
|
-
paths: CodexLayerPaths,
|
|
608
|
-
action: (inspection: CodexLayerInspection) => T,
|
|
609
|
-
): T {
|
|
610
|
-
return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)))
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/** Every file in this layer Codex might already be reading hooks from. */
|
|
614
|
-
export function hookDefinitionFiles(
|
|
615
|
-
harness: HookInstallableHarness,
|
|
616
|
-
global: boolean,
|
|
617
|
-
cwd: string,
|
|
618
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
619
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
620
|
-
): string[] {
|
|
621
|
-
if (harness !== 'codex') return [settingsFile(harness, global, cwd, env, platform)]
|
|
622
|
-
const paths = codexLayerPaths(global, cwd, env, platform)
|
|
623
|
-
return [paths.hooksJson, paths.configToml]
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Layers where *Notifai* is installed in both Codex representations.
|
|
628
|
-
*
|
|
629
|
-
* A layer that uses `hooks.json` and inline `[hooks]` at once is not by itself
|
|
630
|
-
* a Notifai fault: Codex supports both, loads both, runs every matching
|
|
631
|
-
* handler, and already prints its own "prefer a single representation" warning
|
|
632
|
-
* at startup. Foreign handlers in the other file are the user's — often written
|
|
633
|
-
* by another tool that manages that file — and repeating Codex's warning as a
|
|
634
|
-
* Notifai failure demanded an edit to configuration Notifai does not own, for a
|
|
635
|
-
* condition where every handler still fires exactly once.
|
|
636
|
-
*
|
|
637
|
-
* What is ours to diagnose is Notifai in both files. Then one event really does
|
|
638
|
-
* send two Notification Requests per turn, and the copy outside the write
|
|
639
|
-
* target keeps firing an older definition after the next install refreshes only
|
|
640
|
-
* the other one. Uninstall strips Notifai from both files, so the remedy is
|
|
641
|
-
* exact — and it never touches a foreign handler.
|
|
642
|
-
*/
|
|
643
|
-
export function codexRepresentationProblems(
|
|
644
|
-
cwd: string,
|
|
645
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
646
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
647
|
-
): string[] {
|
|
648
|
-
return [false, true].flatMap((global) => {
|
|
649
|
-
const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
|
|
650
|
-
if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0) return []
|
|
651
|
-
const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event))
|
|
652
|
-
const consequence =
|
|
653
|
-
doubled.length > 0
|
|
654
|
-
? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
|
|
655
|
-
: `Codex runs both files, so this layer's Notifai handlers are split between them (${[...new Set([...layer.ourJsonEvents, ...layer.ourTomlEvents])].join(', ')}) and the next install refreshes only one file`
|
|
656
|
-
const scope = global ? ' --global' : ''
|
|
657
|
-
return [
|
|
658
|
-
`Notifai hooks are installed in both ${layer.paths.hooksJson} and ${layer.paths.configToml}; ${consequence}. Run \`notifai hooks uninstall --harness codex${scope}\` and then \`notifai hooks install --harness codex${scope}\` to leave exactly one copy; foreign hooks in either file are left alone.`,
|
|
659
|
-
]
|
|
660
|
-
})
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Layers where Notifai and someone else each own one Codex representation.
|
|
665
|
-
*
|
|
666
|
-
* This is not a fault and never fails a check. Codex loads `hooks.json` and
|
|
667
|
-
* inline `[hooks]` together and runs every matching handler, so both sets fire
|
|
668
|
-
* exactly once — but Codex also prints "prefer one representation per layer"
|
|
669
|
-
* at startup, and a user who sees that warning deserves to know which file is
|
|
670
|
-
* Notifai's, which is not, and that Notifai will not touch the one it does not
|
|
671
|
-
* own. Saying nothing here is what made the warning look like Notifai's bug.
|
|
672
|
-
*/
|
|
673
|
-
export function codexCoexistenceNotes(
|
|
674
|
-
cwd: string,
|
|
675
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
676
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
677
|
-
): string[] {
|
|
678
|
-
return [false, true].flatMap((global) => {
|
|
679
|
-
const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
|
|
680
|
-
const inJson = layer.ourJsonEvents.length > 0
|
|
681
|
-
const inToml = layer.ourTomlEvents.length > 0
|
|
682
|
-
if (inJson === inToml) return []
|
|
683
|
-
const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml
|
|
684
|
-
const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson
|
|
685
|
-
const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents
|
|
686
|
-
if (theirEvents.length === 0) return []
|
|
687
|
-
const stop = theirEvents.includes('Stop')
|
|
688
|
-
? " That file's Stop handler can end a turn before Notifai's answer arrives, because Codex lets any Stop handler stop continuation."
|
|
689
|
-
: ''
|
|
690
|
-
return [
|
|
691
|
-
`Notifai's Codex hooks are in ${ours}. ${theirs} defines hooks Notifai does not own (${theirEvents.join(', ')}) and Notifai will not modify it. Codex loads both files and runs every matching handler, so each set fires exactly once; its "prefer one representation per layer" startup warning is reporting that, not a Notifai fault.${stop}`,
|
|
692
|
-
]
|
|
693
|
-
})
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* What `CODEX_HOME` is doing to this shell, when it is doing anything.
|
|
698
|
-
*
|
|
699
|
-
* Codex replaces its whole home when the variable is set, so hooks installed
|
|
700
|
-
* from a shell without it are simply absent here, and its trust store is keyed
|
|
701
|
-
* by absolute file path — a config copied between homes arrives untrusted.
|
|
702
|
-
* Both facts turn into "Notifai is not wired" reports that look like a bug in
|
|
703
|
-
* the installer, so doctor names the home it actually inspected.
|
|
704
|
-
*/
|
|
705
|
-
export function codexHomeNote(
|
|
706
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
707
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
708
|
-
): string | null {
|
|
709
|
-
const effective = codexGlobalDir(env, platform)
|
|
710
|
-
const accountDefault = path.join(harnessAccountHome(env, platform), '.codex')
|
|
711
|
-
if (effective === accountDefault) return null
|
|
712
|
-
return `CODEX_HOME points Codex at ${effective}, so ${accountDefault} is not read by Codex in this shell at all. Global hooks installed from a shell without CODEX_HOME live there and are invisible here; Codex also keys hook trust by absolute file path, so hooks copied between homes need approving again in each one.`
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function tryLoadSettings(file: string): SettingsDocument | null {
|
|
716
|
-
if (!existsSync(file)) return null
|
|
717
|
-
try {
|
|
718
|
-
return loadSettings(file)
|
|
719
|
-
} catch {
|
|
720
|
-
return null
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
|
|
725
|
-
return Object.entries(hooks ?? {})
|
|
726
|
-
.filter(([, value]) => Array.isArray(value))
|
|
727
|
-
.map(([event]) => event)
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/** Events this document defines a Notifai handler for, by their event key. */
|
|
731
|
-
function ourHandlerEvents(document: SettingsDocument | null): string[] {
|
|
732
|
-
if (document === null) return []
|
|
733
|
-
return [...new Set(locateHandlers(document).map((handler) => handler.event))]
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
737
|
-
/**
|
|
738
|
-
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
739
|
-
*
|
|
740
|
-
* `CLAUDE.md` counts because it is Claude Code's own project file and is
|
|
741
|
-
* frequently the only marker: a repository can be worked in daily through
|
|
742
|
-
* Claude Code and never accumulate a `.claude/` directory.
|
|
743
|
-
*
|
|
744
|
-
* `AGENTS.md` deliberately counts for nothing. It began as a Codex
|
|
745
|
-
* convention and is now read by most agent tooling — including Claude Code,
|
|
746
|
-
* and including this repository, where the two filenames are the same
|
|
747
|
-
* document. Treating it as evidence of any one harness would be a guess
|
|
748
|
-
* dressed up as detection.
|
|
749
|
-
*/
|
|
750
|
-
function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
|
|
751
|
-
const found: HookInstallableHarness[] = []
|
|
752
|
-
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
|
|
753
|
-
found.push('claude-code')
|
|
754
|
-
}
|
|
755
|
-
if (existsSync(path.join(cwd, '.codex'))) found.push('codex')
|
|
756
|
-
if (existsSync(path.join(cwd, '.cursor'))) found.push('cursor')
|
|
757
|
-
if (existsSync(path.join(cwd, '.opencode'))) found.push('opencode')
|
|
758
|
-
if (existsSync(path.join(cwd, '.openclaw'))) found.push('openclaw')
|
|
759
|
-
return found
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
763
|
-
function globalHarnessEvidence(
|
|
764
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
765
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
766
|
-
): HookInstallableHarness[] {
|
|
767
|
-
const home = harnessAccountHome(env, platform)
|
|
768
|
-
const found: HookInstallableHarness[] = []
|
|
769
|
-
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
770
|
-
if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
|
|
771
|
-
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
772
|
-
if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
|
|
773
|
-
if (openclawHasGlobalEvidence(existsSync, env, platform)) found.push('openclaw')
|
|
774
|
-
return found
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Every supported harness this project or machine shows evidence of, in
|
|
779
|
-
* declared order. Project markers come first, then machine installs that the
|
|
780
|
-
* project did not already name.
|
|
781
|
-
*
|
|
782
|
-
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
783
|
-
*/
|
|
784
|
-
export function detectedHarnesses(
|
|
785
|
-
cwd: string,
|
|
786
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
787
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
788
|
-
): HookInstallableHarness[] {
|
|
789
|
-
const seen = new Set<HookInstallableHarness>([
|
|
790
|
-
...localHarnessEvidence(cwd),
|
|
791
|
-
...globalHarnessEvidence(env, platform),
|
|
792
|
-
])
|
|
793
|
-
return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness))
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/**
|
|
797
|
-
* Which *single* harness to wire when a caller still needs exactly one, or
|
|
798
|
-
* null when it is genuinely unclear.
|
|
799
|
-
*
|
|
800
|
-
* Project evidence decides, and machine evidence is consulted only when the
|
|
801
|
-
* project offers none. Several markers are not a failure of detection — they
|
|
802
|
-
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
803
|
-
* this helper stays for uninstall and any caller that cannot take a list.
|
|
804
|
-
*/
|
|
805
|
-
export function detectHarness(
|
|
806
|
-
cwd: string,
|
|
807
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
808
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
809
|
-
): HookInstallableHarness | null {
|
|
810
|
-
const local = localHarnessEvidence(cwd)
|
|
811
|
-
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
812
|
-
const global = globalHarnessEvidence(env, platform)
|
|
813
|
-
return global.length === 1 ? global[0]! : null
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
export interface SettingsDocument {
|
|
817
|
-
hooks?: HookConfig
|
|
818
|
-
[key: string]: unknown
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function readCursorSettings(file: string): CursorSettingsDocument {
|
|
822
|
-
if (!existsSync(file)) return { version: 1 }
|
|
823
|
-
const source = readOwnedRegularFile(file)
|
|
824
|
-
try {
|
|
825
|
-
const parsed: unknown = JSON.parse(source)
|
|
826
|
-
return typeof parsed === 'object' && parsed !== null
|
|
827
|
-
? (parsed as CursorSettingsDocument)
|
|
828
|
-
: { version: 1 }
|
|
829
|
-
} catch {
|
|
830
|
-
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function isOurCommand(command: string, scriptPath: string): boolean {
|
|
835
|
-
if (command.includes(OWNER_MARKER)) return true
|
|
836
|
-
return (
|
|
837
|
-
command.includes(`${scriptPath}' hook `) ||
|
|
838
|
-
command.includes(`${scriptPath}" hook `) ||
|
|
839
|
-
command.includes(`${scriptPath} hook `) ||
|
|
840
|
-
isLegacyNotifaiCommand(command)
|
|
841
|
-
)
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
/** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
|
|
845
|
-
function isLegacyNotifaiCommand(command: string): boolean {
|
|
846
|
-
const hook = `['"]?\\s+hook (?:session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\\b`
|
|
847
|
-
return (
|
|
848
|
-
new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
|
|
849
|
-
new RegExp(
|
|
850
|
-
`(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`,
|
|
851
|
-
'i',
|
|
852
|
-
).test(command) ||
|
|
853
|
-
new RegExp(
|
|
854
|
-
`[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`,
|
|
855
|
-
'i',
|
|
856
|
-
).test(command)
|
|
857
|
-
)
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
export function mergeCursorHooks(
|
|
861
|
-
existing: CursorSettingsDocument,
|
|
862
|
-
incoming: CursorHookConfig,
|
|
863
|
-
scriptPath: string,
|
|
864
|
-
): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
|
|
865
|
-
const hooks: CursorHookConfig = {}
|
|
866
|
-
const added: string[] = []
|
|
867
|
-
const replaced: string[] = []
|
|
868
|
-
const removed: string[] = []
|
|
869
|
-
|
|
870
|
-
for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
|
|
871
|
-
const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
|
|
872
|
-
if (foreign.length !== handlers.length) {
|
|
873
|
-
if (event in incoming) replaced.push(event)
|
|
874
|
-
else removed.push(event)
|
|
875
|
-
}
|
|
876
|
-
if (foreign.length > 0) hooks[event] = foreign
|
|
877
|
-
}
|
|
878
|
-
for (const [event, handlers] of Object.entries(incoming)) {
|
|
879
|
-
if (!replaced.includes(event)) added.push(event)
|
|
880
|
-
hooks[event] = [...(hooks[event] ?? []), ...handlers]
|
|
881
|
-
}
|
|
882
|
-
return { document: { ...existing, version: 1, hooks }, added, replaced, removed }
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
export function removeCursorHooks(
|
|
886
|
-
existing: CursorSettingsDocument,
|
|
887
|
-
scriptPath: string,
|
|
888
|
-
): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
|
|
889
|
-
const hooks: CursorHookConfig = {}
|
|
890
|
-
const replaced: string[] = []
|
|
891
|
-
for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
|
|
892
|
-
const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
|
|
893
|
-
if (foreign.length !== handlers.length) replaced.push(event)
|
|
894
|
-
if (foreign.length > 0) hooks[event] = foreign
|
|
895
|
-
}
|
|
896
|
-
return { document: { ...existing, version: 1, hooks }, added: [], replaced, removed: [] }
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function readSettings(file: string): SettingsDocument {
|
|
900
|
-
if (!existsSync(file)) return {}
|
|
901
|
-
const source = readOwnedRegularFile(file)
|
|
902
|
-
try {
|
|
903
|
-
const parsed: unknown = JSON.parse(source)
|
|
904
|
-
return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
|
|
905
|
-
} catch {
|
|
906
|
-
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* True when a single handler is ours. Deliberately per-handler, not per-group:
|
|
912
|
-
* removing a whole group destroyed a user's own handler if they had added one
|
|
913
|
-
* beside ours inside the same matcher group.
|
|
914
|
-
*/
|
|
915
|
-
function isOurHandler(handler: HookHandler, scriptPath: string): boolean {
|
|
916
|
-
return isOurCommand(handler.command, scriptPath)
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
/** Drops only our handlers, keeping the group and anyone else's handlers. */
|
|
920
|
-
function withoutOurs(groups: HookGroup[], scriptPath: string): { groups: HookGroup[]; removed: boolean } {
|
|
921
|
-
let removed = false
|
|
922
|
-
const kept: HookGroup[] = []
|
|
923
|
-
for (const group of groups) {
|
|
924
|
-
const hooks = group.hooks.filter((handler) => {
|
|
925
|
-
const ours = isOurHandler(handler, scriptPath)
|
|
926
|
-
if (ours) removed = true
|
|
927
|
-
return !ours
|
|
928
|
-
})
|
|
929
|
-
if (hooks.length > 0) kept.push({ ...group, hooks })
|
|
930
|
-
}
|
|
931
|
-
return { groups: kept, removed }
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
export interface MergeResult {
|
|
935
|
-
document: SettingsDocument
|
|
936
|
-
added: string[]
|
|
937
|
-
replaced: string[]
|
|
938
|
-
/** Events an older build installed that this one no longer serves. */
|
|
939
|
-
removed: string[]
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
/**
|
|
943
|
-
* Adds our handlers without disturbing anyone else's. Re-running replaces only
|
|
944
|
-
* the groups we previously wrote, so an upgrade that changes a timeout does not
|
|
945
|
-
* accumulate duplicate hooks.
|
|
946
|
-
*
|
|
947
|
-
* Handlers are stripped from EVERY event, not just the ones being installed.
|
|
948
|
-
* Only rewriting incoming events left a dropped event's handler in place for
|
|
949
|
-
* ever, and since the binary no longer implements it, it exited 2 with
|
|
950
|
-
* "Unknown hook event" every time the harness fired it — a permanent hook
|
|
951
|
-
* failure that reinstalling could not clear.
|
|
952
|
-
*/
|
|
953
|
-
export function mergeHooks(
|
|
954
|
-
existing: SettingsDocument,
|
|
955
|
-
incoming: HookConfig,
|
|
956
|
-
scriptPath: string,
|
|
957
|
-
): MergeResult {
|
|
958
|
-
const hooks: HookConfig = {}
|
|
959
|
-
const added: string[] = []
|
|
960
|
-
const replaced: string[] = []
|
|
961
|
-
const removed: string[] = []
|
|
962
|
-
|
|
963
|
-
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
964
|
-
if (!Array.isArray(groups)) {
|
|
965
|
-
// Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
|
|
966
|
-
;(hooks as Record<string, unknown>)[event] = groups
|
|
967
|
-
continue
|
|
968
|
-
}
|
|
969
|
-
const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
|
|
970
|
-
if (hadOurs) {
|
|
971
|
-
if (event in incoming) replaced.push(event)
|
|
972
|
-
else removed.push(event)
|
|
973
|
-
}
|
|
974
|
-
if (foreign.length > 0) hooks[event] = foreign
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
for (const [event, groups] of Object.entries(incoming)) {
|
|
978
|
-
if (!replaced.includes(event)) added.push(event)
|
|
979
|
-
hooks[event] = [...(hooks[event] ?? []), ...groups]
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
return { document: { ...existing, hooks }, added, replaced, removed }
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
export function removeHooks(existing: SettingsDocument, scriptPath: string): MergeResult {
|
|
986
|
-
const hooks: HookConfig = {}
|
|
987
|
-
const replaced: string[] = []
|
|
988
|
-
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
989
|
-
if (!Array.isArray(groups)) {
|
|
990
|
-
;(hooks as Record<string, unknown>)[event] = groups
|
|
991
|
-
continue
|
|
992
|
-
}
|
|
993
|
-
const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
|
|
994
|
-
if (removed) replaced.push(event)
|
|
995
|
-
if (foreign.length > 0) hooks[event] = foreign
|
|
996
|
-
}
|
|
997
|
-
const remaining = Object.keys(hooks).length > 0 ? hooks : undefined
|
|
998
|
-
const document = { ...existing }
|
|
999
|
-
if (remaining === undefined) delete document.hooks
|
|
1000
|
-
else document.hooks = remaining
|
|
1001
|
-
return { document, added: [], replaced, removed: [] }
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
/**
|
|
1005
|
-
* Writes the settings file the way a tool that does not own it should.
|
|
1006
|
-
*
|
|
1007
|
-
* Truncating in place lost the user's whole harness configuration if the write
|
|
1008
|
-
* failed halfway, and clobbered a concurrent editor's changes; following a
|
|
1009
|
-
* symlink wrote through to a target they never named. So: refuse
|
|
1010
|
-
* anything that is not a regular file, write a sibling temp file, fsync it, and
|
|
1011
|
-
* rename over the original — atomic within a directory on every platform we
|
|
1012
|
-
* support.
|
|
1013
|
-
*/
|
|
1014
|
-
export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
|
|
1015
|
-
const body = isTomlSettingsPath(file)
|
|
1016
|
-
? tomlBody(file, document as SettingsDocument)
|
|
1017
|
-
: `${JSON.stringify(document, null, 2)}\n`
|
|
1018
|
-
if (
|
|
1019
|
-
(path.basename(file) === 'hooks.json' && isEmptyJsonDocument(body)) ||
|
|
1020
|
-
(isTomlSettingsPath(file) && body.trim() === '')
|
|
1021
|
-
) {
|
|
1022
|
-
if (existsSync(file)) rmSync(file, { force: true })
|
|
1023
|
-
return
|
|
1024
|
-
}
|
|
1025
|
-
if (existsSync(file)) {
|
|
1026
|
-
try {
|
|
1027
|
-
if (readOwnedRegularFile(file) === body) return
|
|
1028
|
-
} catch {
|
|
1029
|
-
// A file we cannot re-read still needs the replacement below.
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
atomicWriteFileSync(file, body, {
|
|
1033
|
-
requireCurrentUserOwner: true,
|
|
1034
|
-
})
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function isEmptyJsonDocument(body: string): boolean {
|
|
1038
|
-
try {
|
|
1039
|
-
const parsed: unknown = JSON.parse(body)
|
|
1040
|
-
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
|
|
1041
|
-
} catch {
|
|
1042
|
-
return false
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
/** Delete an emptied Codex hooks.json and a now-empty layer directory. */
|
|
1047
|
-
export function cleanupEmptiedCodexLayer(paths: CodexLayerPaths): void {
|
|
1048
|
-
if (existsSync(paths.hooksJson)) {
|
|
1049
|
-
try {
|
|
1050
|
-
const document = loadSettings(paths.hooksJson)
|
|
1051
|
-
const keys = Object.keys(document)
|
|
1052
|
-
const hooksEmpty = document.hooks === undefined || Object.keys(document.hooks).length === 0
|
|
1053
|
-
if (keys.every((key) => key === 'hooks') && hooksEmpty) {
|
|
1054
|
-
rmSync(paths.hooksJson, { force: true })
|
|
1055
|
-
}
|
|
1056
|
-
} catch {
|
|
1057
|
-
// Leave a file uninstall could not parse; the caller already reported it.
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
if (!existsSync(paths.dir)) return
|
|
1061
|
-
try {
|
|
1062
|
-
if (readdirSync(paths.dir).length === 0) rmdirSync(paths.dir)
|
|
1063
|
-
} catch {
|
|
1064
|
-
// A concurrently replaced directory is not ours to fight.
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
/**
|
|
1069
|
-
* A TOML file rewritten around its `[hooks]` tables, not over them.
|
|
1070
|
-
*
|
|
1071
|
-
* `config.toml` is the user's, and for most of them it is hand-written: model
|
|
1072
|
-
* choices, sandbox settings, MCP servers, and the comments explaining why. A
|
|
1073
|
-
* parse-and-restringify write returns all of that as data and none of it as
|
|
1074
|
-
* the document they wrote — comments gone, order normalized, spacing theirs no
|
|
1075
|
-
* longer. Notifai adds three hook handlers; it has no business reflowing the
|
|
1076
|
-
* rest.
|
|
1077
|
-
*
|
|
1078
|
-
* So everything outside `[hooks...]` is carried across as the exact bytes it
|
|
1079
|
-
* came in as, and only the hooks tables are regenerated — re-emitted at the end
|
|
1080
|
-
* of the file, because TOML does not care where a table sits and splicing into
|
|
1081
|
-
* the middle would mean reasoning about a region line by line. If the result
|
|
1082
|
-
* cannot be proven to parse back to the document asked for, this falls back to
|
|
1083
|
-
* the whole-file rewrite rather than risk a file it half-understood.
|
|
1084
|
-
*/
|
|
1085
|
-
function tomlBody(file: string, document: SettingsDocument): string {
|
|
1086
|
-
const whole = `${stringifyToml(document)}\n`
|
|
1087
|
-
if (!existsSync(file)) return whole
|
|
1088
|
-
let previous: string
|
|
1089
|
-
try {
|
|
1090
|
-
previous = readOwnedRegularFile(file)
|
|
1091
|
-
} catch {
|
|
1092
|
-
return whole
|
|
1093
|
-
}
|
|
1094
|
-
return spliceTomlHooks(previous, document) ?? whole
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/** A TOML table header line, e.g. `[hooks.state."…"]` or `[[hooks.Stop]]`. */
|
|
1098
|
-
const TOML_TABLE_HEADER = /^\s*\[\[?([^[\]]+)\]\]?\s*(?:#.*)?$/
|
|
1099
|
-
|
|
1100
|
-
/**
|
|
1101
|
-
* The first key in a dotted TOML key path, unquoting it if it is quoted.
|
|
1102
|
-
* `hooks.state."/a/b:stop:0:0"` is rooted at `hooks`, and so is `hooks.Stop`.
|
|
1103
|
-
*/
|
|
1104
|
-
function firstTomlKey(keyPath: string): string {
|
|
1105
|
-
const trimmed = keyPath.trim()
|
|
1106
|
-
const quote = trimmed[0]
|
|
1107
|
-
if (quote === '"' || quote === "'") {
|
|
1108
|
-
const end = trimmed.indexOf(quote, 1)
|
|
1109
|
-
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
|
|
1110
|
-
}
|
|
1111
|
-
const dot = trimmed.indexOf('.')
|
|
1112
|
-
return (dot === -1 ? trimmed : trimmed.slice(0, dot)).trim()
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* `source` with its hooks tables replaced by `document`'s, or null when that
|
|
1117
|
-
* cannot be done safely — an inline top-level `hooks` key, or a result that
|
|
1118
|
-
* does not parse back to exactly the document asked for.
|
|
1119
|
-
*/
|
|
1120
|
-
function spliceTomlHooks(source: string, document: SettingsDocument): string | null {
|
|
1121
|
-
const kept: string[] = []
|
|
1122
|
-
let inHooks = false
|
|
1123
|
-
for (const line of source.split('\n')) {
|
|
1124
|
-
const header = TOML_TABLE_HEADER.exec(line)
|
|
1125
|
-
if (header !== null) inHooks = firstTomlKey(header[1] ?? '') === 'hooks'
|
|
1126
|
-
// A root-level `hooks = …` or `hooks.x = …` would survive the splice and
|
|
1127
|
-
// then collide with the tables appended below.
|
|
1128
|
-
if (!inHooks && /^\s*hooks\s*[.=]/.test(line)) return null
|
|
1129
|
-
if (!inHooks) kept.push(line)
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
const hooks = document.hooks
|
|
1133
|
-
const body = hooks === undefined ? '' : stringifyToml({ hooks })
|
|
1134
|
-
const head = kept.join('\n').trimEnd()
|
|
1135
|
-
const spliced =
|
|
1136
|
-
body === '' ? `${head}\n` : head === '' ? `${body}\n` : `${head}\n\n${body}\n`
|
|
1137
|
-
|
|
1138
|
-
try {
|
|
1139
|
-
if (!sameTomlValue(parseToml(spliced), document)) return null
|
|
1140
|
-
} catch {
|
|
1141
|
-
return null
|
|
1142
|
-
}
|
|
1143
|
-
return spliced
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/** Structural equality for parsed TOML, which carries dates as well as data. */
|
|
1147
|
-
function sameTomlValue(a: unknown, b: unknown): boolean {
|
|
1148
|
-
if (a === b) return true
|
|
1149
|
-
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
|
|
1150
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
1151
|
-
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
1152
|
-
return a.every((item, index) => sameTomlValue(item, b[index]))
|
|
1153
|
-
}
|
|
1154
|
-
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
|
1155
|
-
const left = a as Record<string, unknown>
|
|
1156
|
-
const right = b as Record<string, unknown>
|
|
1157
|
-
const keys = Object.keys(left)
|
|
1158
|
-
if (keys.length !== Object.keys(right).length) return false
|
|
1159
|
-
return keys.every((key) => key in right && sameTomlValue(left[key], right[key]))
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
export function loadSettings(file: string): SettingsDocument {
|
|
1163
|
-
return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file)
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
function isTomlSettingsPath(file: string): boolean {
|
|
1167
|
-
return file.endsWith('.toml')
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
function readTomlSettings(file: string): SettingsDocument {
|
|
1171
|
-
if (!existsSync(file)) return {}
|
|
1172
|
-
const source = readOwnedRegularFile(file)
|
|
1173
|
-
try {
|
|
1174
|
-
const parsed: unknown = parseToml(source)
|
|
1175
|
-
return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
|
|
1176
|
-
} catch {
|
|
1177
|
-
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
export function loadCursorSettings(file: string): CursorSettingsDocument {
|
|
1182
|
-
return readCursorSettings(file)
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// ---------------------------------------------------------------------------
|
|
1186
|
-
// Discovery — answering "will my hooks actually run?" without a live test
|
|
1187
|
-
// ---------------------------------------------------------------------------
|
|
1188
|
-
|
|
1189
|
-
/** One of our handlers as it sits in a settings file, with its position. */
|
|
1190
|
-
export interface InstalledHandler {
|
|
1191
|
-
event: string
|
|
1192
|
-
/** Index of the matcher group within the event, as the harness sees it. */
|
|
1193
|
-
groupIndex: number
|
|
1194
|
-
/** Index of the handler within its group. */
|
|
1195
|
-
handlerIndex: number
|
|
1196
|
-
command: string
|
|
1197
|
-
/** Declared process lifetime, used to detect config/install drift. */
|
|
1198
|
-
timeout?: number
|
|
1199
|
-
async?: boolean
|
|
1200
|
-
statusMessage?: string
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
export interface Installation {
|
|
1204
|
-
harness: HookInstallableHarness
|
|
1205
|
-
file: string
|
|
1206
|
-
global: boolean
|
|
1207
|
-
handlers: InstalledHandler[]
|
|
1208
|
-
/** Structural defects that require reinstalling this generated adapter. */
|
|
1209
|
-
problems?: string[]
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
/** Codex's canonical event spelling inside its persisted trust keys. */
|
|
1213
|
-
function codexEventName(event: string): string {
|
|
1214
|
-
return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
/** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
|
|
1218
|
-
function canonicalValue(value: unknown): unknown {
|
|
1219
|
-
if (Array.isArray(value)) return value.map(canonicalValue)
|
|
1220
|
-
if (typeof value !== 'object' || value === null) return value
|
|
1221
|
-
return Object.fromEntries(
|
|
1222
|
-
Object.entries(value as Record<string, unknown>)
|
|
1223
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
1224
|
-
.map(([key, child]) => [key, canonicalValue(child)]),
|
|
1225
|
-
)
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* Best-effort mirror of the identity current Codex builds compare with
|
|
1230
|
-
* `hooks.state.*.trusted_hash`. This persisted format is diagnostic evidence,
|
|
1231
|
-
* not an API: Codex's `/hooks` UI remains authoritative and Notifai never
|
|
1232
|
-
* writes the trust store.
|
|
1233
|
-
*/
|
|
1234
|
-
export function codexHookIdentityHash(handler: InstalledHandler): string {
|
|
1235
|
-
const command = {
|
|
1236
|
-
type: 'command',
|
|
1237
|
-
command: handler.command,
|
|
1238
|
-
timeout: Math.max(1, handler.timeout ?? 600),
|
|
1239
|
-
async: handler.async ?? false,
|
|
1240
|
-
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1241
|
-
}
|
|
1242
|
-
const normalized = canonicalValue({
|
|
1243
|
-
event_name: codexEventName(handler.event),
|
|
1244
|
-
hooks: [command],
|
|
1245
|
-
})
|
|
1246
|
-
return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
1250
|
-
const file = path.join(codexGlobalDir(env), 'config.toml')
|
|
1251
|
-
if (!existsSync(file)) return {}
|
|
1252
|
-
try {
|
|
1253
|
-
const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
1254
|
-
const hooks = parsed['hooks']
|
|
1255
|
-
if (typeof hooks !== 'object' || hooks === null) return {}
|
|
1256
|
-
const state = (hooks as Record<string, unknown>)['state']
|
|
1257
|
-
return typeof state === 'object' && state !== null ? state as Record<string, unknown> : {}
|
|
1258
|
-
} catch {
|
|
1259
|
-
return {}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/** Best-effort mirror of current Codex's persisted key for one handler. */
|
|
1264
|
-
export function codexTrustKey(
|
|
1265
|
-
installation: Installation,
|
|
1266
|
-
handler: InstalledHandler,
|
|
1267
|
-
): string {
|
|
1268
|
-
let source = installation.file
|
|
1269
|
-
try {
|
|
1270
|
-
source = path.join(realpathSync(path.dirname(source)), path.basename(source))
|
|
1271
|
-
} catch {
|
|
1272
|
-
// Codex falls back to the logical source path when canonicalization fails,
|
|
1273
|
-
// so the diagnostic must do the same.
|
|
1274
|
-
}
|
|
1275
|
-
return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
/**
|
|
1279
|
-
* Trust defects that make installed Codex handlers look present while Codex
|
|
1280
|
-
* skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
|
|
1281
|
-
* review UI, never writing the trust store on the user's behalf. Because the
|
|
1282
|
-
* persisted shape is not a public Codex contract, this diagnosis can drift;
|
|
1283
|
-
* `/hooks` remains the source of truth.
|
|
1284
|
-
*/
|
|
1285
|
-
export function codexTrustProblems(
|
|
1286
|
-
installations: Installation[],
|
|
1287
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
1288
|
-
): string[] {
|
|
1289
|
-
const state = codexTrustState(env)
|
|
1290
|
-
return installations
|
|
1291
|
-
.filter((installation) => installation.harness === 'codex')
|
|
1292
|
-
.flatMap((installation) =>
|
|
1293
|
-
installation.handlers.flatMap((handler) => {
|
|
1294
|
-
const key = codexTrustKey(installation, handler)
|
|
1295
|
-
const entry = state[key]
|
|
1296
|
-
const trustedHash =
|
|
1297
|
-
typeof entry === 'object' && entry !== null
|
|
1298
|
-
? (entry as Record<string, unknown>)['trusted_hash']
|
|
1299
|
-
: undefined
|
|
1300
|
-
const currentHash = codexHookIdentityHash(handler)
|
|
1301
|
-
if (
|
|
1302
|
-
typeof entry === 'object' &&
|
|
1303
|
-
entry !== null &&
|
|
1304
|
-
(entry as Record<string, unknown>)['enabled'] === false
|
|
1305
|
-
) {
|
|
1306
|
-
return [
|
|
1307
|
-
`${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
|
|
1308
|
-
]
|
|
1309
|
-
}
|
|
1310
|
-
if (trustedHash === currentHash) return []
|
|
1311
|
-
return [
|
|
1312
|
-
`${handler.event} in ${installation.file} is ${typeof trustedHash === 'string' ? 'changed since it was trusted' : 'not trusted'}; open \`/hooks\` in Codex and approve the Notifai handler`,
|
|
1313
|
-
]
|
|
1314
|
-
}),
|
|
1315
|
-
)
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
/**
|
|
1319
|
-
* Any Notifai handler, whatever checkout wrote it.
|
|
1320
|
-
*
|
|
1321
|
-
* Deliberately looser than `isOurHandler`: this answers "has Notifai been set
|
|
1322
|
-
* up here at all", and a handler installed from a second checkout is still
|
|
1323
|
-
* evidence that it has (and is itself worth reporting).
|
|
1324
|
-
*/
|
|
1325
|
-
function isNotifaiCommand(command: string): boolean {
|
|
1326
|
-
return / hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(command)
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
/** Every place either harness would read a Notifai handler from. */
|
|
1330
|
-
export function findInstallations(
|
|
1331
|
-
cwd: string,
|
|
1332
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
1333
|
-
adapterHome?: string,
|
|
1334
|
-
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
1335
|
-
): Installation[] {
|
|
1336
|
-
const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath
|
|
1337
|
-
const commandOptions: HookCommandOptions = {
|
|
1338
|
-
platform,
|
|
1339
|
-
...(nodePath === undefined ? {} : { nodePath }),
|
|
1340
|
-
}
|
|
1341
|
-
const found: Installation[] = []
|
|
1342
|
-
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
1343
|
-
for (const global of [false, true]) {
|
|
1344
|
-
const files = hookDefinitionFiles(harness, global, cwd, env, platform)
|
|
1345
|
-
for (const file of files) {
|
|
1346
|
-
if (!existsSync(file)) continue
|
|
1347
|
-
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
1348
|
-
// is reported as one installation covering all three events rather than
|
|
1349
|
-
// parsed for handlers.
|
|
1350
|
-
if (harness === 'opencode' || harness === 'openclaw') {
|
|
1351
|
-
let source: string
|
|
1352
|
-
try {
|
|
1353
|
-
source = readOwnedRegularFile(file)
|
|
1354
|
-
} catch {
|
|
1355
|
-
continue
|
|
1356
|
-
}
|
|
1357
|
-
const target =
|
|
1358
|
-
harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source)
|
|
1359
|
-
if (target === null) continue
|
|
1360
|
-
const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode'
|
|
1361
|
-
const problems = [
|
|
1362
|
-
...(!target.current
|
|
1363
|
-
? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
|
|
1364
|
-
: []),
|
|
1365
|
-
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
1366
|
-
? [
|
|
1367
|
-
`${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1368
|
-
]
|
|
1369
|
-
: []),
|
|
1370
|
-
]
|
|
1371
|
-
const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS
|
|
1372
|
-
found.push({
|
|
1373
|
-
harness,
|
|
1374
|
-
file,
|
|
1375
|
-
global,
|
|
1376
|
-
...(problems.length > 0 ? { problems } : {}),
|
|
1377
|
-
handlers: events.map(([event, hookEvent]) => ({
|
|
1378
|
-
event,
|
|
1379
|
-
groupIndex: 0,
|
|
1380
|
-
handlerIndex: 0,
|
|
1381
|
-
command: hookCommand(target.adapter, hookEvent, harness, {
|
|
1382
|
-
...commandOptions,
|
|
1383
|
-
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
1384
|
-
}),
|
|
1385
|
-
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
1386
|
-
})),
|
|
1387
|
-
})
|
|
1388
|
-
continue
|
|
1389
|
-
}
|
|
1390
|
-
if (harness === 'cursor') {
|
|
1391
|
-
let document: CursorSettingsDocument
|
|
1392
|
-
try {
|
|
1393
|
-
document = readCursorSettings(file)
|
|
1394
|
-
} catch {
|
|
1395
|
-
continue
|
|
1396
|
-
}
|
|
1397
|
-
const handlers = locateCursorHandlers(document)
|
|
1398
|
-
if (handlers.length > 0) {
|
|
1399
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
|
|
1400
|
-
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
1401
|
-
}
|
|
1402
|
-
continue
|
|
1403
|
-
}
|
|
1404
|
-
let document: SettingsDocument
|
|
1405
|
-
try {
|
|
1406
|
-
document = loadSettings(file)
|
|
1407
|
-
} catch {
|
|
1408
|
-
continue
|
|
1409
|
-
}
|
|
1410
|
-
const handlers = locateHandlers(document)
|
|
1411
|
-
if (handlers.length > 0) {
|
|
1412
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
|
|
1413
|
-
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
return found
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
function harnessMarkerProblems(
|
|
1422
|
-
harness: HookInstallableHarness,
|
|
1423
|
-
handlers: InstalledHandler[],
|
|
1424
|
-
adapterHome?: string,
|
|
1425
|
-
options: HookCommandOptions = {},
|
|
1426
|
-
): string[] {
|
|
1427
|
-
const problems: string[] = []
|
|
1428
|
-
if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
|
|
1429
|
-
problems.push(
|
|
1430
|
-
`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1431
|
-
)
|
|
1432
|
-
}
|
|
1433
|
-
const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `
|
|
1434
|
-
if (!handlers.every((handler) => handler.command.startsWith(expected))) {
|
|
1435
|
-
problems.push(
|
|
1436
|
-
'installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter',
|
|
1437
|
-
)
|
|
1438
|
-
}
|
|
1439
|
-
return problems
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
|
|
1443
|
-
const handlers: InstalledHandler[] = []
|
|
1444
|
-
for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
|
|
1445
|
-
eventHandlers.forEach((handler, handlerIndex) => {
|
|
1446
|
-
if (isNotifaiCommand(handler.command)) {
|
|
1447
|
-
handlers.push({
|
|
1448
|
-
event,
|
|
1449
|
-
groupIndex: 0,
|
|
1450
|
-
handlerIndex,
|
|
1451
|
-
command: handler.command,
|
|
1452
|
-
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
1453
|
-
})
|
|
1454
|
-
}
|
|
1455
|
-
})
|
|
1456
|
-
}
|
|
1457
|
-
return handlers
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
function locateAllHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
1461
|
-
const handlers: InstalledHandler[] = []
|
|
1462
|
-
for (const [event, groups] of Object.entries(document.hooks ?? {})) {
|
|
1463
|
-
if (!Array.isArray(groups)) continue
|
|
1464
|
-
groups.forEach((group, groupIndex) => {
|
|
1465
|
-
group.hooks?.forEach((handler, handlerIndex) => {
|
|
1466
|
-
if (typeof handler?.command !== 'string') return
|
|
1467
|
-
handlers.push({
|
|
1468
|
-
event,
|
|
1469
|
-
groupIndex,
|
|
1470
|
-
handlerIndex,
|
|
1471
|
-
command: handler.command,
|
|
1472
|
-
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
1473
|
-
...(handler.async === undefined ? {} : { async: handler.async }),
|
|
1474
|
-
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1475
|
-
})
|
|
1476
|
-
})
|
|
1477
|
-
})
|
|
1478
|
-
}
|
|
1479
|
-
return handlers
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
function locateHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
1483
|
-
return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command))
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/** The hook event a handler's command actually invokes, e.g. `stop`. */
|
|
1487
|
-
export function handlerEvent(command: string): string | null {
|
|
1488
|
-
return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
function readOwnedRegularFile(file: string): string {
|
|
1492
|
-
const stat = lstatSync(file)
|
|
1493
|
-
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1494
|
-
throw new Error(`${file} is not a regular file; refusing to read it.`)
|
|
1495
|
-
}
|
|
1496
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
1497
|
-
if (uid !== undefined && stat.uid !== uid) {
|
|
1498
|
-
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
1499
|
-
}
|
|
1500
|
-
return readFileSync(file, 'utf8')
|
|
1501
|
-
}
|