@raidiant/notifai 10.1.7 → 11.0.2
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 +56 -0
- package/README.md +23 -13
- package/dist/claude-wake.d.ts +1 -2
- package/dist/claude-wake.js +15 -61
- package/dist/claude-wake.js.map +1 -1
- package/dist/cli-bin.js +3 -14
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-contract.d.ts +12 -0
- package/dist/cli-contract.js +19 -0
- package/dist/cli-contract.js.map +1 -0
- package/dist/cli-release.d.ts +0 -1
- package/dist/cli-release.js +5 -21
- package/dist/cli-release.js.map +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/codex-wake.d.ts +1 -1
- package/dist/codex-wake.js +8 -36
- package/dist/codex-wake.js.map +1 -1
- package/dist/command-session.js +2 -1
- package/dist/command-session.js.map +1 -1
- package/dist/commands-acknowledge.d.ts +13 -0
- package/dist/commands-acknowledge.js +67 -0
- package/dist/commands-acknowledge.js.map +1 -0
- package/dist/commands-ask.d.ts +63 -0
- package/dist/commands-ask.js +407 -0
- package/dist/commands-ask.js.map +1 -0
- package/dist/commands-close.d.ts +6 -0
- package/dist/commands-close.js +197 -0
- package/dist/commands-close.js.map +1 -0
- package/dist/commands-config.js +2 -2
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.js +2 -2
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +61 -120
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +0 -1
- package/dist/commands-harness-context.js +2 -4
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-diagnostics.d.ts +27 -0
- package/dist/commands-hook-diagnostics.js +97 -0
- package/dist/commands-hook-diagnostics.js.map +1 -0
- package/dist/commands-hook-install.d.ts +26 -0
- package/dist/commands-hook-install.js +551 -0
- package/dist/commands-hook-install.js.map +1 -0
- package/dist/commands-hook-run.d.ts +29 -0
- package/dist/commands-hook-run.js +517 -0
- package/dist/commands-hook-run.js.map +1 -0
- package/dist/commands-init.d.ts +2 -7
- package/dist/commands-init.js +59 -92
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.js +35 -3
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +5 -5
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-update.js +17 -31
- package/dist/commands-update.js.map +1 -1
- package/dist/commands.d.ts +11 -6
- package/dist/commands.js +9 -4
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +14 -4
- package/dist/config.js +35 -9
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +3 -0
- package/dist/file-lock.js +33 -17
- package/dist/file-lock.js.map +1 -1
- package/dist/guidance-content.js +30 -15
- package/dist/guidance-content.js.map +1 -1
- package/dist/hook-acknowledgements.d.ts +42 -0
- package/dist/hook-acknowledgements.js +316 -0
- package/dist/hook-acknowledgements.js.map +1 -0
- package/dist/hook-events.d.ts +6 -0
- package/dist/hook-events.js +22 -0
- package/dist/hook-events.js.map +1 -0
- package/dist/hook-gates.d.ts +7 -0
- package/dist/hook-gates.js +21 -0
- package/dist/hook-gates.js.map +1 -0
- package/dist/hook-lifecycle.d.ts +93 -0
- package/dist/{hooks.js → hook-lifecycle.js} +61 -1359
- package/dist/hook-lifecycle.js.map +1 -0
- package/dist/hook-project-sessions.d.ts +28 -0
- package/dist/hook-project-sessions.js +141 -0
- package/dist/hook-project-sessions.js.map +1 -0
- package/dist/hook-question-lock.d.ts +7 -0
- package/dist/hook-question-lock.js +173 -0
- package/dist/hook-question-lock.js.map +1 -0
- package/dist/hook-question-retirement.d.ts +57 -0
- package/dist/hook-question-retirement.js +261 -0
- package/dist/hook-question-retirement.js.map +1 -0
- package/dist/hook-question-state.d.ts +45 -0
- package/dist/hook-question-state.js +196 -0
- package/dist/hook-question-state.js.map +1 -0
- package/dist/hook-session-state.d.ts +31 -0
- package/dist/hook-session-state.js +249 -0
- package/dist/hook-session-state.js.map +1 -0
- package/dist/{hooks.d.ts → hook-types.d.ts} +17 -236
- package/dist/hook-types.js +4 -0
- package/dist/hook-types.js.map +1 -0
- package/dist/install-hooks.d.ts +75 -60
- package/dist/install-hooks.js +299 -209
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +3 -16
- package/dist/interactive.js +19 -95
- package/dist/interactive.js.map +1 -1
- package/dist/local-path.d.ts +7 -0
- package/dist/local-path.js +27 -0
- package/dist/local-path.js.map +1 -0
- package/dist/node-floor.js +3 -10
- package/dist/node-floor.js.map +1 -1
- package/dist/npm-invocation.d.ts +34 -0
- package/dist/npm-invocation.js +53 -0
- package/dist/npm-invocation.js.map +1 -0
- package/dist/openclaw-plugin.d.ts +12 -3
- package/dist/openclaw-plugin.js +42 -16
- package/dist/openclaw-plugin.js.map +1 -1
- package/dist/opencode-plugin.d.ts +2 -2
- package/dist/opencode-plugin.js +4 -6
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +11 -13
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +6 -0
- package/dist/project-enablement.js +11 -27
- package/dist/project-enablement.js.map +1 -1
- package/dist/question-settlement-process.d.ts +1 -1
- package/dist/readiness.d.ts +0 -2
- package/dist/readiness.js +0 -4
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +8 -4
- package/dist/send.js +145 -26
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +3 -1
- package/dist/session-activation.js +16 -0
- package/dist/session-activation.js.map +1 -1
- package/dist/skill-source/manifest.json +4 -4
- package/dist/skill-source/notifai/SKILL.md +60 -60
- package/dist/skill-source/notifai/references/harness-setup.md +42 -16
- package/dist/ui/config-view.d.ts +0 -2
- package/dist/ui/config-view.js +1 -5
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -3
- package/dist/ui/theme.js +0 -1
- package/dist/ui/theme.js.map +1 -1
- package/dist/version.d.ts +22 -0
- package/dist/version.js +51 -0
- package/dist/version.js.map +1 -0
- package/dist/wake-support.d.ts +8 -0
- package/dist/wake-support.js +38 -0
- package/dist/wake-support.js.map +1 -0
- package/package.json +2 -2
- package/dist/cli-update-contract.d.ts +0 -7
- package/dist/cli-update-contract.js +0 -10
- package/dist/cli-update-contract.js.map +0 -1
- package/dist/commands-hooks.d.ts +0 -160
- package/dist/commands-hooks.js +0 -1800
- package/dist/commands-hooks.js.map +0 -1
- package/dist/hooks.js.map +0 -1
package/dist/install-hooks.js
CHANGED
|
@@ -5,10 +5,11 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
|
5
5
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
6
6
|
import { withTargetFileLock } from './file-lock.js';
|
|
7
7
|
import { hookAdapterPath, hookHostPlatform, inspectHookAdapter, } from './hook-adapter.js';
|
|
8
|
-
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js';
|
|
9
|
-
import { OPENCLAW_EVENTS, openclawHasGlobalEvidence, openclawPluginPath, openclawPluginTarget, } from './openclaw-plugin.js';
|
|
8
|
+
import { OPENCODE_PLUGIN_FILENAME, opencodePluginPath, opencodePluginTarget, } from './opencode-plugin.js';
|
|
9
|
+
import { OPENCLAW_EVENTS, legacyOpenclawProjectPluginPath, openclawHasGlobalEvidence, openclawPluginPath, openclawPluginTarget, } from './openclaw-plugin.js';
|
|
10
10
|
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js';
|
|
11
11
|
import { accountHome } from './platform.js';
|
|
12
|
+
import { sameLocalPath } from './local-path.js';
|
|
12
13
|
import { NON_ROUTING_STOP_TIMEOUT_SECONDS, QUESTION_STOP_TIMEOUT_SECONDS, } from './question-timing.js';
|
|
13
14
|
/**
|
|
14
15
|
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
@@ -142,6 +143,10 @@ export function buildHookConfig(options) {
|
|
|
142
143
|
command: hookCommand(adapterPath, 'session-start', options.harness, commandOptions),
|
|
143
144
|
// Activation is local context only: no config, credentials, or network.
|
|
144
145
|
timeout: 5,
|
|
146
|
+
// Codex spills additionalContext above ~2,500 tokens to a preview
|
|
147
|
+
// file. SessionStart may emit up to 24,000 bytes, so pass the full
|
|
148
|
+
// context. Changing this field requires one `/hooks` re-approval.
|
|
149
|
+
...(options.harness === 'codex' ? { additionalContextLimit: 0 } : {}),
|
|
145
150
|
},
|
|
146
151
|
],
|
|
147
152
|
},
|
|
@@ -226,30 +231,48 @@ export function buildCursorHookConfig(options) {
|
|
|
226
231
|
};
|
|
227
232
|
}
|
|
228
233
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* questions pushed to is not a property of the repository.
|
|
234
|
+
* The one file each harness reads Notifai's lifecycle wiring from: the current
|
|
235
|
+
* User's account, in that harness's active home.
|
|
232
236
|
*/
|
|
233
|
-
export function settingsFile(harness,
|
|
237
|
+
export function settingsFile(harness, env = process.env, platform = process.platform) {
|
|
234
238
|
switch (harness) {
|
|
235
239
|
// OpenCode has no settings document to merge into — its adapter is a
|
|
236
240
|
// generated plugin module, so it owns a whole file instead.
|
|
237
241
|
case 'opencode':
|
|
238
|
-
return opencodePluginPath(
|
|
242
|
+
return opencodePluginPath(env, platform);
|
|
239
243
|
case 'openclaw':
|
|
240
|
-
return openclawPluginPath(
|
|
241
|
-
case 'cursor':
|
|
242
|
-
|
|
243
|
-
return global
|
|
244
|
-
? path.join(home, '.cursor', 'hooks.json')
|
|
245
|
-
: path.join(cwd, '.cursor', 'hooks.json');
|
|
246
|
-
}
|
|
244
|
+
return openclawPluginPath(env, platform);
|
|
245
|
+
case 'cursor':
|
|
246
|
+
return path.join(harnessAccountHome(env, platform), '.cursor', 'hooks.json');
|
|
247
247
|
case 'claude-code':
|
|
248
|
-
return
|
|
249
|
-
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
|
|
250
|
-
: path.join(cwd, '.claude', 'settings.local.json');
|
|
248
|
+
return path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json');
|
|
251
249
|
case 'codex':
|
|
252
|
-
return inspectCodexLayer(
|
|
250
|
+
return inspectCodexLayer(codexMachineLayerPaths(env, platform)).writeTarget;
|
|
251
|
+
default:
|
|
252
|
+
return assertNeverHarness(harness);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Files an older build may have left inside this Project, which Notifai now
|
|
257
|
+
* only ever reads in order to remove.
|
|
258
|
+
*
|
|
259
|
+
* Nothing installs here any more. Every path is still enumerated because a
|
|
260
|
+
* leftover Project-scoped handler keeps firing beside the Machine one, and
|
|
261
|
+
* silently leaving it would be exactly the duplicate-routing defect dropping
|
|
262
|
+
* the second scope was meant to end.
|
|
263
|
+
*/
|
|
264
|
+
export function legacyProjectHookFiles(harness, cwd) {
|
|
265
|
+
switch (harness) {
|
|
266
|
+
case 'opencode':
|
|
267
|
+
return [path.join(cwd, '.opencode', 'plugins', OPENCODE_PLUGIN_FILENAME)];
|
|
268
|
+
case 'openclaw':
|
|
269
|
+
return [legacyOpenclawProjectPluginPath(cwd)];
|
|
270
|
+
case 'cursor':
|
|
271
|
+
return [path.join(cwd, '.cursor', 'hooks.json')];
|
|
272
|
+
case 'claude-code':
|
|
273
|
+
return [path.join(cwd, '.claude', 'settings.local.json')];
|
|
274
|
+
case 'codex':
|
|
275
|
+
return codexLegacyProjectLayers(cwd).flatMap((paths) => [paths.hooksJson, paths.configToml]);
|
|
253
276
|
default:
|
|
254
277
|
return assertNeverHarness(harness);
|
|
255
278
|
}
|
|
@@ -288,8 +311,7 @@ export function codexProjectRoot(cwd) {
|
|
|
288
311
|
try {
|
|
289
312
|
if (statSync(entry).isDirectory())
|
|
290
313
|
return path.dirname(entry);
|
|
291
|
-
|
|
292
|
-
return linked === null ? cwd : path.dirname(commonGitDir(linked));
|
|
314
|
+
return linkedWorktreeMainRoot(entry) ?? cwd;
|
|
293
315
|
}
|
|
294
316
|
catch {
|
|
295
317
|
// An unreadable or exotic checkout is not worth failing an install over;
|
|
@@ -297,29 +319,6 @@ export function codexProjectRoot(cwd) {
|
|
|
297
319
|
return cwd;
|
|
298
320
|
}
|
|
299
321
|
}
|
|
300
|
-
/**
|
|
301
|
-
* The `.codex` directory that has to exist for Codex to load project hooks at
|
|
302
|
-
* all, or null when nothing extra is needed.
|
|
303
|
-
*
|
|
304
|
-
* Codex splits the two halves of "which project am I in": it discovers the
|
|
305
|
-
* project layer by walking up from cwd for a `.codex` directory, but resolves
|
|
306
|
-
* the hook file inside that layer against the main repository. In an ordinary
|
|
307
|
-
* checkout both land on the same directory and the split is invisible. In a
|
|
308
|
-
* worktree they diverge, and writing only the main repository's file leaves it
|
|
309
|
-
* unread — Codex never looks, because nothing at or above cwd told it there was
|
|
310
|
-
* a project layer to load. Proven 2026-08-03: with cwd in a worktree, an *empty*
|
|
311
|
-
* `.codex` directory there was the difference between the main repository's
|
|
312
|
-
* handler running and nothing running.
|
|
313
|
-
*/
|
|
314
|
-
export function codexLayerDir(cwd) {
|
|
315
|
-
const entry = findGitEntry(cwd);
|
|
316
|
-
if (entry === null)
|
|
317
|
-
return null;
|
|
318
|
-
const worktreeRoot = path.dirname(entry);
|
|
319
|
-
if (path.resolve(worktreeRoot) === path.resolve(codexProjectRoot(cwd)))
|
|
320
|
-
return null;
|
|
321
|
-
return path.join(worktreeRoot, '.codex');
|
|
322
|
-
}
|
|
323
322
|
/** The nearest `.git` at or above `from`, or null outside a repository. */
|
|
324
323
|
function findGitEntry(from) {
|
|
325
324
|
let dir = path.resolve(from);
|
|
@@ -333,22 +332,58 @@ function findGitEntry(from) {
|
|
|
333
332
|
dir = parent;
|
|
334
333
|
}
|
|
335
334
|
}
|
|
336
|
-
/** `.git` in a linked worktree is a file naming that worktree's git directory. */
|
|
337
|
-
function worktreeGitDir(file) {
|
|
338
|
-
const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(file, 'utf8'));
|
|
339
|
-
if (match === null)
|
|
340
|
-
return null;
|
|
341
|
-
return path.resolve(path.dirname(file), match[1].trim());
|
|
342
|
-
}
|
|
343
335
|
/**
|
|
344
|
-
*
|
|
345
|
-
*
|
|
336
|
+
* Resolve the main checkout only from Git's reciprocal linked-worktree
|
|
337
|
+
* metadata. A repository can contain an arbitrary `.git` file, so its one-way
|
|
338
|
+
* `gitdir`/`commondir` claims are not authority to write outside the Project.
|
|
346
339
|
*/
|
|
347
|
-
function
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
340
|
+
function linkedWorktreeMainRoot(gitFile) {
|
|
341
|
+
if (!isOwnedRegularFile(gitFile))
|
|
342
|
+
return null;
|
|
343
|
+
const gitdirClaim = readPathMarker(gitFile, /^gitdir:[ \t]+([^\r\n]+)\r?\n?$/);
|
|
344
|
+
if (gitdirClaim === null)
|
|
345
|
+
return null;
|
|
346
|
+
const gitDir = path.resolve(path.dirname(gitFile), gitdirClaim);
|
|
347
|
+
if (!isOwnedDirectory(gitDir))
|
|
348
|
+
return null;
|
|
349
|
+
const commonClaim = readPathMarker(path.join(gitDir, 'commondir'), /^([^\r\n]+)\r?\n?$/);
|
|
350
|
+
const backlinkClaim = readPathMarker(path.join(gitDir, 'gitdir'), /^([^\r\n]+)\r?\n?$/);
|
|
351
|
+
if (commonClaim === null || backlinkClaim === null)
|
|
352
|
+
return null;
|
|
353
|
+
const commonDir = path.resolve(gitDir, commonClaim);
|
|
354
|
+
const backlink = path.resolve(gitDir, backlinkClaim);
|
|
355
|
+
const worktreesDir = path.join(commonDir, 'worktrees');
|
|
356
|
+
if (path.basename(commonDir).toLowerCase() !== '.git' ||
|
|
357
|
+
!isOwnedDirectory(commonDir) ||
|
|
358
|
+
!isOwnedDirectory(worktreesDir) ||
|
|
359
|
+
!sameLocalPath(path.dirname(realpathSync(gitDir)), realpathSync(worktreesDir)) ||
|
|
360
|
+
!sameLocalPath(backlink, gitFile)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const mainRoot = path.dirname(commonDir);
|
|
364
|
+
return isOwnedDirectory(mainRoot) ? mainRoot : null;
|
|
365
|
+
}
|
|
366
|
+
function readPathMarker(file, pattern) {
|
|
367
|
+
if (!isOwnedRegularFile(file))
|
|
368
|
+
return null;
|
|
369
|
+
const match = pattern.exec(readFileSync(file, 'utf8'));
|
|
370
|
+
const claim = match?.[1]?.trim();
|
|
371
|
+
return claim === undefined || claim === '' ? null : claim;
|
|
372
|
+
}
|
|
373
|
+
function isOwnedRegularFile(file) {
|
|
374
|
+
return isOwnedEntry(file, 'file');
|
|
375
|
+
}
|
|
376
|
+
function isOwnedDirectory(file) {
|
|
377
|
+
return isOwnedEntry(file, 'directory');
|
|
378
|
+
}
|
|
379
|
+
function isOwnedEntry(file, kind) {
|
|
380
|
+
const metadata = lstatSync(file);
|
|
381
|
+
if (metadata.isSymbolicLink())
|
|
382
|
+
return false;
|
|
383
|
+
if (kind === 'file' ? !metadata.isFile() : !metadata.isDirectory())
|
|
384
|
+
return false;
|
|
385
|
+
const uid = process.getuid?.();
|
|
386
|
+
return uid === undefined || metadata.uid === uid;
|
|
352
387
|
}
|
|
353
388
|
/**
|
|
354
389
|
* Harness-specific home variables relocate the whole active harness home.
|
|
@@ -379,54 +414,75 @@ export function configHome(env, variable, fallback, platform = process.platform)
|
|
|
379
414
|
export function codexGlobalDir(env = process.env, platform = process.platform) {
|
|
380
415
|
return configHome(env, 'CODEX_HOME', '.codex', platform);
|
|
381
416
|
}
|
|
382
|
-
|
|
383
|
-
export function codexLayerPaths(global, cwd, env = process.env, platform = process.platform) {
|
|
384
|
-
const dir = global
|
|
385
|
-
? codexGlobalDir(env, platform)
|
|
386
|
-
: path.join(codexProjectRoot(cwd), '.codex');
|
|
417
|
+
function codexLayerPathsIn(dir) {
|
|
387
418
|
return {
|
|
388
419
|
dir,
|
|
389
420
|
hooksJson: path.join(dir, 'hooks.json'),
|
|
390
421
|
configToml: path.join(dir, 'config.toml'),
|
|
391
422
|
};
|
|
392
423
|
}
|
|
424
|
+
/** The two files Codex will look at in the Machine layer Notifai installs into. */
|
|
425
|
+
export function codexMachineLayerPaths(env = process.env, platform = process.platform) {
|
|
426
|
+
return codexLayerPathsIn(codexGlobalDir(env, platform));
|
|
427
|
+
}
|
|
393
428
|
/**
|
|
394
|
-
*
|
|
395
|
-
* whichever single hook representation the layer already uses, and inline
|
|
396
|
-
* `config.toml` for a hook-empty or already-mixed layer.
|
|
429
|
+
* Project layers an older build may have written into, for removal only.
|
|
397
430
|
*
|
|
398
|
-
* Codex
|
|
399
|
-
*
|
|
400
|
-
* the
|
|
401
|
-
*
|
|
402
|
-
|
|
431
|
+
* Codex resolves a project hook file against the main repository, so a linked
|
|
432
|
+
* worktree has two candidate directories: the main checkout's `.codex`, which
|
|
433
|
+
* Codex reads, and the worktree's own, which older builds sometimes created and
|
|
434
|
+
* Codex never reads. Both are legacy residue and both are enumerated here.
|
|
435
|
+
*/
|
|
436
|
+
export function codexLegacyProjectLayers(cwd) {
|
|
437
|
+
const dirs = [path.join(codexProjectRoot(cwd), '.codex')];
|
|
438
|
+
const entry = findGitEntry(cwd);
|
|
439
|
+
if (entry !== null) {
|
|
440
|
+
const worktreeLayer = path.join(path.dirname(entry), '.codex');
|
|
441
|
+
if (!dirs.some((dir) => path.resolve(dir) === path.resolve(worktreeLayer))) {
|
|
442
|
+
dirs.push(worktreeLayer);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return dirs.map(codexLayerPathsIn);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Where Notifai writes in this Codex layer. An existing single Notifai
|
|
449
|
+
* representation is stable: upgrades refresh it in place because Codex keys
|
|
450
|
+
* approval by both handler identity and source path. Only a fresh installation
|
|
451
|
+
* chooses between `hooks.json` and inline `config.toml`.
|
|
403
452
|
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
453
|
+
* `config.toml` is the User's whole Codex configuration and Codex rewrites it
|
|
454
|
+
* itself for `[hooks.state]`; keeping Notifai out of it bounds the blast radius
|
|
455
|
+
* to hooks, removes a second writer racing those trust records, and replaces
|
|
456
|
+
* TOML splicing with the same JSON merge Claude Code uses. Joining an inline
|
|
457
|
+
* layer that already has the User's handlers is the one exception, and it
|
|
458
|
+
* exists only so Codex never prints its dual-representation warning.
|
|
459
|
+
*
|
|
460
|
+
* A fresh installation uses `hooks.json` unless foreign inline handlers make
|
|
461
|
+
* `config.toml` the layer's established representation. `[hooks.state]` is the
|
|
462
|
+
* trust store rather than a hook definition, so it never counts as a
|
|
463
|
+
* representation and is preserved through every write.
|
|
408
464
|
*/
|
|
409
465
|
export function inspectCodexLayer(paths) {
|
|
410
466
|
const jsonDocument = tryLoadSettings(paths.hooksJson);
|
|
411
467
|
const tomlDocument = tryLoadSettings(paths.configToml);
|
|
412
|
-
const jsonEvents = hookEventNames(jsonDocument?.hooks);
|
|
413
|
-
const tomlEvents = hookEventNames(tomlDocument?.hooks);
|
|
414
468
|
const ourJsonEvents = ourHandlerEvents(jsonDocument);
|
|
415
469
|
const ourTomlEvents = ourHandlerEvents(tomlDocument);
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
: ourTomlEvents.length > 0
|
|
419
|
-
? paths.configToml
|
|
420
|
-
: jsonEvents.length > 0 && tomlEvents.length === 0
|
|
421
|
-
? paths.hooksJson
|
|
422
|
-
: paths.configToml;
|
|
470
|
+
const foreignTomlHooks = tomlDocument !== null &&
|
|
471
|
+
locateAllHandlers(tomlDocument).some((handler) => !isNotifaiCommand(handler.command));
|
|
423
472
|
return {
|
|
424
473
|
paths,
|
|
425
|
-
jsonEvents,
|
|
426
|
-
tomlEvents,
|
|
474
|
+
jsonEvents: hookEventNames(jsonDocument?.hooks),
|
|
475
|
+
tomlEvents: hookEventNames(tomlDocument?.hooks),
|
|
427
476
|
ourJsonEvents,
|
|
428
477
|
ourTomlEvents,
|
|
429
|
-
|
|
478
|
+
foreignTomlHooks,
|
|
479
|
+
writeTarget: ourTomlEvents.length > 0 && ourJsonEvents.length === 0
|
|
480
|
+
? paths.configToml
|
|
481
|
+
: ourJsonEvents.length > 0 && ourTomlEvents.length === 0
|
|
482
|
+
? paths.hooksJson
|
|
483
|
+
: foreignTomlHooks
|
|
484
|
+
? paths.configToml
|
|
485
|
+
: paths.hooksJson,
|
|
430
486
|
};
|
|
431
487
|
}
|
|
432
488
|
/**
|
|
@@ -436,15 +492,15 @@ export function inspectCodexLayer(paths) {
|
|
|
436
492
|
export function withCodexLayerTransaction(paths, action) {
|
|
437
493
|
return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)));
|
|
438
494
|
}
|
|
439
|
-
/** Every file
|
|
440
|
-
export function
|
|
495
|
+
/** Every Machine-layer file this harness might already be reading hooks from. */
|
|
496
|
+
export function machineHookFiles(harness, env = process.env, platform = process.platform) {
|
|
441
497
|
if (harness !== 'codex')
|
|
442
|
-
return [settingsFile(harness,
|
|
443
|
-
const paths =
|
|
498
|
+
return [settingsFile(harness, env, platform)];
|
|
499
|
+
const paths = codexMachineLayerPaths(env, platform);
|
|
444
500
|
return [paths.hooksJson, paths.configToml];
|
|
445
501
|
}
|
|
446
502
|
/**
|
|
447
|
-
*
|
|
503
|
+
* The Machine layer with *Notifai* installed in both Codex representations.
|
|
448
504
|
*
|
|
449
505
|
* A layer that uses `hooks.json` and inline `[hooks]` at once is not by itself
|
|
450
506
|
* a Notifai fault: Codex supports both, loads both, runs every matching
|
|
@@ -460,23 +516,21 @@ export function hookDefinitionFiles(harness, global, cwd, env = process.env, pla
|
|
|
460
516
|
* the other one. Uninstall strips Notifai from both files, so the remedy is
|
|
461
517
|
* exact — and it never touches a foreign handler.
|
|
462
518
|
*/
|
|
463
|
-
export function codexRepresentationProblems(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
`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.`,
|
|
475
|
-
];
|
|
476
|
-
});
|
|
519
|
+
export function codexRepresentationProblems(env = process.env, platform = process.platform) {
|
|
520
|
+
const layer = inspectCodexLayer(codexMachineLayerPaths(env, platform));
|
|
521
|
+
if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0)
|
|
522
|
+
return [];
|
|
523
|
+
const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event));
|
|
524
|
+
const consequence = doubled.length > 0
|
|
525
|
+
? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
|
|
526
|
+
: `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`;
|
|
527
|
+
return [
|
|
528
|
+
`Notifai hooks are installed in both ${layer.paths.hooksJson} and ${layer.paths.configToml}; ${consequence}. Run \`notifai hooks uninstall --harness codex\` and then \`notifai hooks install --harness codex\` to leave exactly one copy; foreign hooks in either file are left alone.`,
|
|
529
|
+
];
|
|
477
530
|
}
|
|
478
531
|
/**
|
|
479
|
-
*
|
|
532
|
+
* The Machine layer where Notifai and someone else each own one Codex
|
|
533
|
+
* representation.
|
|
480
534
|
*
|
|
481
535
|
* This is not a fault and never fails a check. Codex loads `hooks.json` and
|
|
482
536
|
* inline `[hooks]` together and runs every matching handler, so both sets fire
|
|
@@ -485,25 +539,23 @@ export function codexRepresentationProblems(cwd, env = process.env, platform = p
|
|
|
485
539
|
* Notifai's, which is not, and that Notifai will not touch the one it does not
|
|
486
540
|
* own. Saying nothing here is what made the warning look like Notifai's bug.
|
|
487
541
|
*/
|
|
488
|
-
export function codexCoexistenceNotes(
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
];
|
|
506
|
-
});
|
|
542
|
+
export function codexCoexistenceNotes(env = process.env, platform = process.platform) {
|
|
543
|
+
const layer = inspectCodexLayer(codexMachineLayerPaths(env, platform));
|
|
544
|
+
const inJson = layer.ourJsonEvents.length > 0;
|
|
545
|
+
const inToml = layer.ourTomlEvents.length > 0;
|
|
546
|
+
if (inJson === inToml)
|
|
547
|
+
return [];
|
|
548
|
+
const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml;
|
|
549
|
+
const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson;
|
|
550
|
+
const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents;
|
|
551
|
+
if (theirEvents.length === 0)
|
|
552
|
+
return [];
|
|
553
|
+
const stop = theirEvents.includes('Stop')
|
|
554
|
+
? " That file's Stop handler can end a turn before Notifai's answer arrives, because Codex lets any Stop handler stop continuation."
|
|
555
|
+
: '';
|
|
556
|
+
return [
|
|
557
|
+
`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}`,
|
|
558
|
+
];
|
|
507
559
|
}
|
|
508
560
|
/**
|
|
509
561
|
* What `CODEX_HOME` is doing to this shell, when it is doing anything.
|
|
@@ -679,7 +731,16 @@ export function removeCursorHooks(existing, scriptPath) {
|
|
|
679
731
|
if (foreign.length > 0)
|
|
680
732
|
hooks[event] = foreign;
|
|
681
733
|
}
|
|
682
|
-
|
|
734
|
+
const document = { ...existing };
|
|
735
|
+
if (Object.keys(hooks).length > 0)
|
|
736
|
+
document.hooks = hooks;
|
|
737
|
+
else
|
|
738
|
+
delete document.hooks;
|
|
739
|
+
// `version` is Cursor's schema marker, which Notifai writes only because it
|
|
740
|
+
// is writing hooks. Alone it is residue, not settings.
|
|
741
|
+
if (Object.keys(document).length === 1 && document.version !== undefined)
|
|
742
|
+
delete document.version;
|
|
743
|
+
return { document, added: [], replaced, removed: [] };
|
|
683
744
|
}
|
|
684
745
|
function readSettings(file) {
|
|
685
746
|
if (!existsSync(file))
|
|
@@ -794,7 +855,10 @@ export function applyPlan(file, document) {
|
|
|
794
855
|
const body = isTomlSettingsPath(file)
|
|
795
856
|
? tomlBody(file, document)
|
|
796
857
|
: `${JSON.stringify(document, null, 2)}\n`;
|
|
797
|
-
|
|
858
|
+
// A document with nothing left in it is a file Notifai created and has just
|
|
859
|
+
// emptied. Keeping it would leave `{}` behind as the visible residue of an
|
|
860
|
+
// uninstall; a file that still holds anything of the User's is never empty.
|
|
861
|
+
if ((!isTomlSettingsPath(file) && isEmptyJsonDocument(body)) ||
|
|
798
862
|
(isTomlSettingsPath(file) && body.trim() === '')) {
|
|
799
863
|
if (existsSync(file))
|
|
800
864
|
rmSync(file, { force: true });
|
|
@@ -982,10 +1046,16 @@ function canonicalValue(value) {
|
|
|
982
1046
|
}
|
|
983
1047
|
/**
|
|
984
1048
|
* Best-effort mirror of the identity current Codex builds compare with
|
|
985
|
-
* `hooks.state.*.trusted_hash`.
|
|
986
|
-
*
|
|
987
|
-
*
|
|
1049
|
+
* `hooks.state.*.trusted_hash`. Codex hashes `type`, `command`, `timeout`,
|
|
1050
|
+
* `async`, `statusMessage`, and `additionalContextLimit` (omitting the 2,500
|
|
1051
|
+
* default). This persisted format is diagnostic evidence, not an API: Codex's
|
|
1052
|
+
* `/hooks` UI remains authoritative and Notifai never writes the trust store.
|
|
1053
|
+
*/
|
|
1054
|
+
/**
|
|
1055
|
+
* Codex's default additionalContext spill threshold, in approximate tokens.
|
|
1056
|
+
* Codex strips this value before hashing, so an explicit 2,500 matches omit.
|
|
988
1057
|
*/
|
|
1058
|
+
const CODEX_DEFAULT_ADDITIONAL_CONTEXT_LIMIT = 2_500;
|
|
989
1059
|
export function codexHookIdentityHash(handler) {
|
|
990
1060
|
const command = {
|
|
991
1061
|
type: 'command',
|
|
@@ -993,6 +1063,10 @@ export function codexHookIdentityHash(handler) {
|
|
|
993
1063
|
timeout: Math.max(1, handler.timeout ?? 600),
|
|
994
1064
|
async: handler.async ?? false,
|
|
995
1065
|
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1066
|
+
...(handler.additionalContextLimit === undefined
|
|
1067
|
+
|| handler.additionalContextLimit === CODEX_DEFAULT_ADDITIONAL_CONTEXT_LIMIT
|
|
1068
|
+
? {}
|
|
1069
|
+
: { additionalContextLimit: handler.additionalContextLimit }),
|
|
996
1070
|
};
|
|
997
1071
|
const normalized = canonicalValue({
|
|
998
1072
|
event_name: codexEventName(handler.event),
|
|
@@ -1096,92 +1170,105 @@ export function codexTrustProblems(installations, env = process.env) {
|
|
|
1096
1170
|
function isNotifaiCommand(command) {
|
|
1097
1171
|
return / hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(command);
|
|
1098
1172
|
}
|
|
1099
|
-
/**
|
|
1100
|
-
|
|
1173
|
+
/**
|
|
1174
|
+
* The one Machine installation each harness would read a Notifai handler from.
|
|
1175
|
+
*/
|
|
1176
|
+
export function findInstallations(env = process.env, adapterHome, platform = process.platform) {
|
|
1177
|
+
return HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => collectInstallations(harness, machineHookFiles(harness, env, platform), adapterHome, platform));
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Notifai handlers a previous build left in Project-scoped files.
|
|
1181
|
+
*
|
|
1182
|
+
* These are never an install target and never a fallback: they are residue to
|
|
1183
|
+
* report and remove once the Machine installation is proven current. Foreign
|
|
1184
|
+
* handlers sharing those files are not returned and are never touched.
|
|
1185
|
+
*/
|
|
1186
|
+
export function findLegacyProjectInstallations(cwd, env = process.env, adapterHome, platform = process.platform) {
|
|
1187
|
+
const machine = new Set(HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => machineHookFiles(harness, env, platform).map((file) => path.resolve(file))));
|
|
1188
|
+
return HOOK_INSTALLABLE_HARNESSES.flatMap((harness) => collectInstallations(harness,
|
|
1189
|
+
// A Project checkout that happens to be the harness home would otherwise
|
|
1190
|
+
// report the Machine installation as legacy residue and delete it.
|
|
1191
|
+
legacyProjectHookFiles(harness, cwd).filter((file) => !machine.has(path.resolve(file))), adapterHome, platform));
|
|
1192
|
+
}
|
|
1193
|
+
function collectInstallations(harness, files, adapterHome, platform) {
|
|
1101
1194
|
const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath;
|
|
1102
1195
|
const commandOptions = {
|
|
1103
1196
|
platform,
|
|
1104
1197
|
...(nodePath === undefined ? {} : { nodePath }),
|
|
1105
1198
|
};
|
|
1106
1199
|
const found = [];
|
|
1107
|
-
for (const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
document = readCursorSettings(file);
|
|
1161
|
-
}
|
|
1162
|
-
catch {
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
const handlers = locateCursorHandlers(document);
|
|
1166
|
-
if (handlers.length > 0) {
|
|
1167
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
1168
|
-
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
1169
|
-
}
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
let document;
|
|
1173
|
-
try {
|
|
1174
|
-
document = loadSettings(file);
|
|
1175
|
-
}
|
|
1176
|
-
catch {
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
const handlers = locateHandlers(document);
|
|
1180
|
-
if (handlers.length > 0) {
|
|
1181
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
1182
|
-
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
1183
|
-
}
|
|
1200
|
+
for (const file of files) {
|
|
1201
|
+
if (!existsSync(file))
|
|
1202
|
+
continue;
|
|
1203
|
+
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
1204
|
+
// is reported as one installation covering all three events rather than
|
|
1205
|
+
// parsed for handlers.
|
|
1206
|
+
if (harness === 'opencode' || harness === 'openclaw') {
|
|
1207
|
+
let source;
|
|
1208
|
+
try {
|
|
1209
|
+
source = readOwnedRegularFile(file);
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
const target = harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source);
|
|
1215
|
+
if (target === null)
|
|
1216
|
+
continue;
|
|
1217
|
+
const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode';
|
|
1218
|
+
const problems = [
|
|
1219
|
+
...(!target.current
|
|
1220
|
+
? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
|
|
1221
|
+
: []),
|
|
1222
|
+
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
1223
|
+
? [
|
|
1224
|
+
`${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1225
|
+
]
|
|
1226
|
+
: []),
|
|
1227
|
+
];
|
|
1228
|
+
const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS;
|
|
1229
|
+
found.push({
|
|
1230
|
+
harness,
|
|
1231
|
+
file,
|
|
1232
|
+
...(problems.length > 0 ? { problems } : {}),
|
|
1233
|
+
handlers: events.map(([event, hookEvent]) => ({
|
|
1234
|
+
event,
|
|
1235
|
+
groupIndex: 0,
|
|
1236
|
+
handlerIndex: 0,
|
|
1237
|
+
command: hookCommand(target.adapter, hookEvent, harness, {
|
|
1238
|
+
...commandOptions,
|
|
1239
|
+
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
1240
|
+
}),
|
|
1241
|
+
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
1242
|
+
})),
|
|
1243
|
+
});
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (harness === 'cursor') {
|
|
1247
|
+
let document;
|
|
1248
|
+
try {
|
|
1249
|
+
document = readCursorSettings(file);
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
continue;
|
|
1184
1253
|
}
|
|
1254
|
+
const handlers = locateCursorHandlers(document);
|
|
1255
|
+
if (handlers.length > 0) {
|
|
1256
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
1257
|
+
found.push({ harness, file, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
1258
|
+
}
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
let document;
|
|
1262
|
+
try {
|
|
1263
|
+
document = loadSettings(file);
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const handlers = locateHandlers(document);
|
|
1269
|
+
if (handlers.length > 0) {
|
|
1270
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
1271
|
+
found.push({ harness, file, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
1185
1272
|
}
|
|
1186
1273
|
}
|
|
1187
1274
|
return found;
|
|
@@ -1231,6 +1318,9 @@ function locateAllHandlers(document) {
|
|
|
1231
1318
|
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
1232
1319
|
...(handler.async === undefined ? {} : { async: handler.async }),
|
|
1233
1320
|
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1321
|
+
...(typeof handler.additionalContextLimit === 'number'
|
|
1322
|
+
? { additionalContextLimit: handler.additionalContextLimit }
|
|
1323
|
+
: {}),
|
|
1234
1324
|
});
|
|
1235
1325
|
});
|
|
1236
1326
|
});
|