@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.
Files changed (154) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +23 -13
  3. package/dist/claude-wake.d.ts +1 -2
  4. package/dist/claude-wake.js +15 -61
  5. package/dist/claude-wake.js.map +1 -1
  6. package/dist/cli-bin.js +3 -14
  7. package/dist/cli-bin.js.map +1 -1
  8. package/dist/cli-contract.d.ts +12 -0
  9. package/dist/cli-contract.js +19 -0
  10. package/dist/cli-contract.js.map +1 -0
  11. package/dist/cli-release.d.ts +0 -1
  12. package/dist/cli-release.js +5 -21
  13. package/dist/cli-release.js.map +1 -1
  14. package/dist/client.d.ts +2 -2
  15. package/dist/codex-wake.d.ts +1 -1
  16. package/dist/codex-wake.js +8 -36
  17. package/dist/codex-wake.js.map +1 -1
  18. package/dist/command-session.js +2 -1
  19. package/dist/command-session.js.map +1 -1
  20. package/dist/commands-acknowledge.d.ts +13 -0
  21. package/dist/commands-acknowledge.js +67 -0
  22. package/dist/commands-acknowledge.js.map +1 -0
  23. package/dist/commands-ask.d.ts +63 -0
  24. package/dist/commands-ask.js +407 -0
  25. package/dist/commands-ask.js.map +1 -0
  26. package/dist/commands-close.d.ts +6 -0
  27. package/dist/commands-close.js +197 -0
  28. package/dist/commands-close.js.map +1 -0
  29. package/dist/commands-config.js +2 -2
  30. package/dist/commands-config.js.map +1 -1
  31. package/dist/commands-core.js +2 -2
  32. package/dist/commands-core.js.map +1 -1
  33. package/dist/commands-doctor.js +61 -120
  34. package/dist/commands-doctor.js.map +1 -1
  35. package/dist/commands-harness-context.d.ts +0 -1
  36. package/dist/commands-harness-context.js +2 -4
  37. package/dist/commands-harness-context.js.map +1 -1
  38. package/dist/commands-hook-diagnostics.d.ts +27 -0
  39. package/dist/commands-hook-diagnostics.js +97 -0
  40. package/dist/commands-hook-diagnostics.js.map +1 -0
  41. package/dist/commands-hook-install.d.ts +26 -0
  42. package/dist/commands-hook-install.js +551 -0
  43. package/dist/commands-hook-install.js.map +1 -0
  44. package/dist/commands-hook-run.d.ts +29 -0
  45. package/dist/commands-hook-run.js +517 -0
  46. package/dist/commands-hook-run.js.map +1 -0
  47. package/dist/commands-init.d.ts +2 -7
  48. package/dist/commands-init.js +59 -92
  49. package/dist/commands-init.js.map +1 -1
  50. package/dist/commands-send.js +35 -3
  51. package/dist/commands-send.js.map +1 -1
  52. package/dist/commands-skill.js +5 -5
  53. package/dist/commands-skill.js.map +1 -1
  54. package/dist/commands-update.js +17 -31
  55. package/dist/commands-update.js.map +1 -1
  56. package/dist/commands.d.ts +11 -6
  57. package/dist/commands.js +9 -4
  58. package/dist/commands.js.map +1 -1
  59. package/dist/config.d.ts +14 -4
  60. package/dist/config.js +35 -9
  61. package/dist/config.js.map +1 -1
  62. package/dist/file-lock.d.ts +3 -0
  63. package/dist/file-lock.js +33 -17
  64. package/dist/file-lock.js.map +1 -1
  65. package/dist/guidance-content.js +30 -15
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/hook-acknowledgements.d.ts +42 -0
  68. package/dist/hook-acknowledgements.js +316 -0
  69. package/dist/hook-acknowledgements.js.map +1 -0
  70. package/dist/hook-events.d.ts +6 -0
  71. package/dist/hook-events.js +22 -0
  72. package/dist/hook-events.js.map +1 -0
  73. package/dist/hook-gates.d.ts +7 -0
  74. package/dist/hook-gates.js +21 -0
  75. package/dist/hook-gates.js.map +1 -0
  76. package/dist/hook-lifecycle.d.ts +93 -0
  77. package/dist/{hooks.js → hook-lifecycle.js} +61 -1359
  78. package/dist/hook-lifecycle.js.map +1 -0
  79. package/dist/hook-project-sessions.d.ts +28 -0
  80. package/dist/hook-project-sessions.js +141 -0
  81. package/dist/hook-project-sessions.js.map +1 -0
  82. package/dist/hook-question-lock.d.ts +7 -0
  83. package/dist/hook-question-lock.js +173 -0
  84. package/dist/hook-question-lock.js.map +1 -0
  85. package/dist/hook-question-retirement.d.ts +57 -0
  86. package/dist/hook-question-retirement.js +261 -0
  87. package/dist/hook-question-retirement.js.map +1 -0
  88. package/dist/hook-question-state.d.ts +45 -0
  89. package/dist/hook-question-state.js +196 -0
  90. package/dist/hook-question-state.js.map +1 -0
  91. package/dist/hook-session-state.d.ts +31 -0
  92. package/dist/hook-session-state.js +249 -0
  93. package/dist/hook-session-state.js.map +1 -0
  94. package/dist/{hooks.d.ts → hook-types.d.ts} +17 -236
  95. package/dist/hook-types.js +4 -0
  96. package/dist/hook-types.js.map +1 -0
  97. package/dist/install-hooks.d.ts +75 -60
  98. package/dist/install-hooks.js +299 -209
  99. package/dist/install-hooks.js.map +1 -1
  100. package/dist/interactive.d.ts +3 -16
  101. package/dist/interactive.js +19 -95
  102. package/dist/interactive.js.map +1 -1
  103. package/dist/local-path.d.ts +7 -0
  104. package/dist/local-path.js +27 -0
  105. package/dist/local-path.js.map +1 -0
  106. package/dist/node-floor.js +3 -10
  107. package/dist/node-floor.js.map +1 -1
  108. package/dist/npm-invocation.d.ts +34 -0
  109. package/dist/npm-invocation.js +53 -0
  110. package/dist/npm-invocation.js.map +1 -0
  111. package/dist/openclaw-plugin.d.ts +12 -3
  112. package/dist/openclaw-plugin.js +42 -16
  113. package/dist/openclaw-plugin.js.map +1 -1
  114. package/dist/opencode-plugin.d.ts +2 -2
  115. package/dist/opencode-plugin.js +4 -6
  116. package/dist/opencode-plugin.js.map +1 -1
  117. package/dist/program.js +11 -13
  118. package/dist/program.js.map +1 -1
  119. package/dist/project-enablement.d.ts +6 -0
  120. package/dist/project-enablement.js +11 -27
  121. package/dist/project-enablement.js.map +1 -1
  122. package/dist/question-settlement-process.d.ts +1 -1
  123. package/dist/readiness.d.ts +0 -2
  124. package/dist/readiness.js +0 -4
  125. package/dist/readiness.js.map +1 -1
  126. package/dist/send.d.ts +8 -4
  127. package/dist/send.js +145 -26
  128. package/dist/send.js.map +1 -1
  129. package/dist/session-activation.d.ts +3 -1
  130. package/dist/session-activation.js +16 -0
  131. package/dist/session-activation.js.map +1 -1
  132. package/dist/skill-source/manifest.json +4 -4
  133. package/dist/skill-source/notifai/SKILL.md +60 -60
  134. package/dist/skill-source/notifai/references/harness-setup.md +42 -16
  135. package/dist/ui/config-view.d.ts +0 -2
  136. package/dist/ui/config-view.js +1 -5
  137. package/dist/ui/config-view.js.map +1 -1
  138. package/dist/ui/theme.d.ts +0 -3
  139. package/dist/ui/theme.js +0 -1
  140. package/dist/ui/theme.js.map +1 -1
  141. package/dist/version.d.ts +22 -0
  142. package/dist/version.js +51 -0
  143. package/dist/version.js.map +1 -0
  144. package/dist/wake-support.d.ts +8 -0
  145. package/dist/wake-support.js +38 -0
  146. package/dist/wake-support.js.map +1 -0
  147. package/package.json +2 -2
  148. package/dist/cli-update-contract.d.ts +0 -7
  149. package/dist/cli-update-contract.js +0 -10
  150. package/dist/cli-update-contract.js.map +0 -1
  151. package/dist/commands-hooks.d.ts +0 -160
  152. package/dist/commands-hooks.js +0 -1800
  153. package/dist/commands-hooks.js.map +0 -1
  154. package/dist/hooks.js.map +0 -1
@@ -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
- * Where each harness reads hooks from. Project installs deliberately target the
230
- * gitignored file where one exists: which devices a person wants their
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, global, cwd, env = process.env, platform = process.platform) {
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(global, cwd, env, platform);
242
+ return opencodePluginPath(env, platform);
239
243
  case 'openclaw':
240
- return openclawPluginPath(global, cwd, env, platform);
241
- case 'cursor': {
242
- const home = harnessAccountHome(env, platform);
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 global
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(codexLayerPaths(global, cwd, env, platform)).writeTarget;
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
- const linked = worktreeGitDir(entry);
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
- * A worktree's git directory records the shared one in `commondir`; that shared
345
- * directory is the main repository's `.git`, whose parent is its root.
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 commonGitDir(gitDir) {
348
- const marker = path.join(gitDir, 'commondir');
349
- if (!existsSync(marker))
350
- return gitDir;
351
- return path.resolve(gitDir, readFileSync(marker, 'utf8').trim());
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
- /** The two files Codex will look at for one config layer. */
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
- * Where Notifai writes in this Codex layer: wherever Notifai already is, then
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 supports `hooks.json` and inline `[hooks]` side by side, loads both,
399
- * and runs every matching handler. Reusing the one populated representation is
400
- * the generic layer contract: it avoids creating Codex's dual-representation
401
- * warning and, for a managed layer, writes through the same authority that
402
- * persists the layer rather than a sibling file it may regenerate away.
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
- * Staying put where Notifai already is also keeps reinstalls idempotent and
405
- * preserves the `[hooks.state]` trust hash, which Codex keys by file path and
406
- * handler index. `[hooks.state]` is the trust store, not a hook definition, so
407
- * it does not count as a representation.
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 writeTarget = ourJsonEvents.length > 0 && ourTomlEvents.length === 0
417
- ? paths.hooksJson
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
- writeTarget,
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 in this layer Codex might already be reading hooks from. */
440
- export function hookDefinitionFiles(harness, global, cwd, env = process.env, platform = process.platform) {
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, global, cwd, env, platform)];
443
- const paths = codexLayerPaths(global, cwd, env, platform);
498
+ return [settingsFile(harness, env, platform)];
499
+ const paths = codexMachineLayerPaths(env, platform);
444
500
  return [paths.hooksJson, paths.configToml];
445
501
  }
446
502
  /**
447
- * Layers where *Notifai* is installed in both Codex representations.
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(cwd, env = process.env, platform = process.platform) {
464
- return [false, true].flatMap((global) => {
465
- const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform));
466
- if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0)
467
- return [];
468
- const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event));
469
- const consequence = doubled.length > 0
470
- ? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
471
- : `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`;
472
- const scope = global ? ' --global' : '';
473
- return [
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
- * Layers where Notifai and someone else each own one Codex representation.
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(cwd, env = process.env, platform = process.platform) {
489
- return [false, true].flatMap((global) => {
490
- const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform));
491
- const inJson = layer.ourJsonEvents.length > 0;
492
- const inToml = layer.ourTomlEvents.length > 0;
493
- if (inJson === inToml)
494
- return [];
495
- const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml;
496
- const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson;
497
- const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents;
498
- if (theirEvents.length === 0)
499
- return [];
500
- const stop = theirEvents.includes('Stop')
501
- ? " That file's Stop handler can end a turn before Notifai's answer arrives, because Codex lets any Stop handler stop continuation."
502
- : '';
503
- return [
504
- `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}`,
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
- return { document: { ...existing, version: 1, hooks }, added: [], replaced, removed: [] };
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
- if ((path.basename(file) === 'hooks.json' && isEmptyJsonDocument(body)) ||
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`. This persisted format is diagnostic evidence,
986
- * not an API: Codex's `/hooks` UI remains authoritative and Notifai never
987
- * writes the trust store.
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
- /** Every place either harness would read a Notifai handler from. */
1100
- export function findInstallations(cwd, env = process.env, adapterHome, platform = process.platform) {
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 harness of HOOK_INSTALLABLE_HARNESSES) {
1108
- for (const global of [false, true]) {
1109
- const files = hookDefinitionFiles(harness, global, cwd, env, platform);
1110
- for (const file of files) {
1111
- if (!existsSync(file))
1112
- continue;
1113
- // OpenCode's adapter is a plugin module, not a settings document, so it
1114
- // is reported as one installation covering all three events rather than
1115
- // parsed for handlers.
1116
- if (harness === 'opencode' || harness === 'openclaw') {
1117
- let source;
1118
- try {
1119
- source = readOwnedRegularFile(file);
1120
- }
1121
- catch {
1122
- continue;
1123
- }
1124
- const target = harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source);
1125
- if (target === null)
1126
- continue;
1127
- const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode';
1128
- const problems = [
1129
- ...(!target.current
1130
- ? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
1131
- : []),
1132
- ...(target.adapter !== hookAdapterPath(adapterHome)
1133
- ? [
1134
- `${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
1135
- ]
1136
- : []),
1137
- ];
1138
- const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS;
1139
- found.push({
1140
- harness,
1141
- file,
1142
- global,
1143
- ...(problems.length > 0 ? { problems } : {}),
1144
- handlers: events.map(([event, hookEvent]) => ({
1145
- event,
1146
- groupIndex: 0,
1147
- handlerIndex: 0,
1148
- command: hookCommand(target.adapter, hookEvent, harness, {
1149
- ...commandOptions,
1150
- ...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
1151
- }),
1152
- ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
1153
- })),
1154
- });
1155
- continue;
1156
- }
1157
- if (harness === 'cursor') {
1158
- let document;
1159
- try {
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
  });