@raidiant/notifai 11.0.6 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +6 -0
  3. package/dist/agent-update-notice.d.ts +7 -0
  4. package/dist/agent-update-notice.js +77 -0
  5. package/dist/agent-update-notice.js.map +1 -0
  6. package/dist/cli-release.d.ts +3 -3
  7. package/dist/cli-release.js +12 -16
  8. package/dist/cli-release.js.map +1 -1
  9. package/dist/codex-wake.d.ts +44 -63
  10. package/dist/codex-wake.js +71 -174
  11. package/dist/codex-wake.js.map +1 -1
  12. package/dist/commands-ask.js +3 -1
  13. package/dist/commands-ask.js.map +1 -1
  14. package/dist/commands-close.js +28 -14
  15. package/dist/commands-close.js.map +1 -1
  16. package/dist/commands-core.d.ts +1 -1
  17. package/dist/commands-doctor.js +36 -19
  18. package/dist/commands-doctor.js.map +1 -1
  19. package/dist/commands-guidance.d.ts +1 -1
  20. package/dist/commands-guidance.js +15 -2
  21. package/dist/commands-guidance.js.map +1 -1
  22. package/dist/commands-hook-install.js +146 -54
  23. package/dist/commands-hook-install.js.map +1 -1
  24. package/dist/commands-hook-run.js +21 -13
  25. package/dist/commands-hook-run.js.map +1 -1
  26. package/dist/commands-hook-shape.js +3 -2
  27. package/dist/commands-hook-shape.js.map +1 -1
  28. package/dist/commands-update-check.d.ts +6 -0
  29. package/dist/commands-update-check.js +86 -0
  30. package/dist/commands-update-check.js.map +1 -0
  31. package/dist/commands-update-skill.d.ts +5 -0
  32. package/dist/commands-update-skill.js +36 -0
  33. package/dist/commands-update-skill.js.map +1 -0
  34. package/dist/commands-update.js +29 -1
  35. package/dist/commands-update.js.map +1 -1
  36. package/dist/commands.d.ts +2 -0
  37. package/dist/commands.js +2 -0
  38. package/dist/commands.js.map +1 -1
  39. package/dist/credentials.js +7 -4
  40. package/dist/credentials.js.map +1 -1
  41. package/dist/harnesses.d.ts +1 -1
  42. package/dist/harnesses.js +2 -2
  43. package/dist/harnesses.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +7 -1
  45. package/dist/hook-adapter.js +36 -3
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hook-events.d.ts +69 -2
  48. package/dist/hook-events.js +75 -13
  49. package/dist/hook-events.js.map +1 -1
  50. package/dist/hook-lifecycle.js +44 -15
  51. package/dist/hook-lifecycle.js.map +1 -1
  52. package/dist/hook-question-retirement.d.ts +9 -0
  53. package/dist/hook-question-retirement.js +38 -3
  54. package/dist/hook-question-retirement.js.map +1 -1
  55. package/dist/hook-question-state.d.ts +14 -5
  56. package/dist/hook-question-state.js +55 -24
  57. package/dist/hook-question-state.js.map +1 -1
  58. package/dist/hook-types.d.ts +6 -0
  59. package/dist/install-hooks.d.ts +70 -12
  60. package/dist/install-hooks.js +352 -198
  61. package/dist/install-hooks.js.map +1 -1
  62. package/dist/native-skills.d.ts +2 -0
  63. package/dist/native-skills.js +3 -1
  64. package/dist/native-skills.js.map +1 -1
  65. package/dist/openclaw-plugin.d.ts +0 -1
  66. package/dist/openclaw-plugin.js +0 -7
  67. package/dist/openclaw-plugin.js.map +1 -1
  68. package/dist/opencode-plugin.d.ts +9 -0
  69. package/dist/opencode-plugin.js +19 -2
  70. package/dist/opencode-plugin.js.map +1 -1
  71. package/dist/program.d.ts +3 -1
  72. package/dist/program.js +21 -6
  73. package/dist/program.js.map +1 -1
  74. package/dist/readiness.d.ts +9 -1
  75. package/dist/readiness.js +11 -3
  76. package/dist/readiness.js.map +1 -1
  77. package/dist/session-activation.d.ts +2 -2
  78. package/dist/session-activation.js +8 -7
  79. package/dist/session-activation.js.map +1 -1
  80. package/dist/session-labels.js +6 -6
  81. package/dist/session-labels.js.map +1 -1
  82. package/dist/skill-source/manifest.json +8 -4
  83. package/dist/skill-source/notifai/SKILL.md +14 -16
  84. package/dist/skill-source/notifai/references/harness-setup.md +23 -14
  85. package/dist/skill-source/notifai/references/updates.md +74 -0
  86. package/dist/ui/theme.d.ts +1 -1
  87. package/dist/update-handoff.d.ts +29 -0
  88. package/dist/update-handoff.js +66 -0
  89. package/dist/update-handoff.js.map +1 -0
  90. package/package.json +2 -2
@@ -5,22 +5,13 @@ 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 { OPENCODE_PLUGIN_FILENAME, opencodePluginPath, opencodePluginTarget, } from './opencode-plugin.js';
9
- import { OPENCLAW_EVENTS, legacyOpenclawProjectPluginPath, openclawHasGlobalEvidence, openclawPluginPath, openclawPluginTarget, } from './openclaw-plugin.js';
8
+ import { OPENCODE_PLUGIN_FILENAME, opencodeConfigDir, opencodePluginPath, opencodePluginTarget, } from './opencode-plugin.js';
9
+ import { legacyOpenclawProjectPluginPath, openclawHasGlobalEvidence, openclawPluginPath, openclawPluginTarget, } from './openclaw-plugin.js';
10
10
  import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js';
11
+ import { HOOK_EVENT_COMMAND_RE, HOOK_EVENT_PATTERN, HOOK_EVENT_TABLE, OPENCLAW_EVENTS, OPENCODE_EVENTS, } from './hook-events.js';
11
12
  import { accountHome } from './platform.js';
12
13
  import { sameLocalPath } from './local-path.js';
13
14
  import { NON_ROUTING_STOP_TIMEOUT_SECONDS, QUESTION_STOP_TIMEOUT_SECONDS, } from './question-timing.js';
14
- /**
15
- * The three joints the OpenCode plugin hooks into, as (harness event, the
16
- * `notifai hook` event it runs). The plugin is a module and has no settings
17
- * entries to read, so `findInstallations` reconstructs its handlers from this.
18
- */
19
- const OPENCODE_EVENTS = [
20
- ['UserPromptSubmit', 'user-prompt-submit'],
21
- ['Stop', 'stop'],
22
- ['SessionEnd', 'session-end'],
23
- ];
24
15
  /**
25
16
  * The command each hook runs. Harness definitions know only the stable
26
17
  * user-level adapter. Mutable Node, package-manager, version, and checkout
@@ -98,30 +89,47 @@ export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js';
98
89
  /**
99
90
  * Whether this harness's Stop handler runs detached from the turn.
100
91
  *
101
- * The installer and doctor share this predicate so they agree whether Claude
102
- * runs out of band. Owner lifetime is route-neutral and lives in
92
+ * The installer and doctor share this predicate so they agree which harnesses
93
+ * run out of band. Owner lifetime is route-neutral and lives in
103
94
  * `question-timing.ts`.
95
+ *
96
+ * Codex is detached on every platform because its delivery route is a write to
97
+ * the thread's own durable inbox rather than this hook's stdout: there is no
98
+ * continuation to keep a turn open for, and the queue has no platform
99
+ * dependency to gate on. Claude Code still needs its turn held on Windows,
100
+ * where no inbox socket exists.
104
101
  */
105
102
  export function stopHandlerIsDetached(harness, platform) {
103
+ if (harness === 'codex')
104
+ return true;
106
105
  return harness === 'claude-code' && hookHostPlatform(platform) === 'posix';
107
106
  }
108
107
  /**
109
108
  * The turn-end handler, whose shape is the whole per-harness difference.
110
109
  *
111
- * Claude Code takes the answer over its own inbox socket, so its Stop hook is
112
- * `async: true`: it returns immediately, the terminal is never held, and the
113
- * waiter finishes out of band. Codex and Claude on Windows block and print a
114
- * continuation to stdout. Every Question Routing owner declares the same
115
- * complete-window timeout; changing Codex's definition requires one explicit
116
- * trust approval.
110
+ * Claude Code takes the answer over its own inbox socket and Codex takes it
111
+ * through its thread's durable inbox, so both Stop hooks are `async: true`:
112
+ * they return immediately, the terminal is never held, and the waiter finishes
113
+ * out of band. Only Claude on Windows still blocks and prints a continuation to
114
+ * stdout, because no inbox socket exists there. Every Question Routing owner
115
+ * declares the same complete-window timeout, because the detached waiter must
116
+ * outlive the answer window in every case; what changed for Codex is that a
117
+ * short or missing timeout no longer truncates a held *turn*. Blocking hosts
118
+ * also set `statusMessage` so the held turn is not mistaken for a hang.
117
119
  */
120
+ export const BLOCKING_STOP_STATUS_MESSAGE = 'Notifai: waiting for your answer';
118
121
  function stopHandler(adapterPath, harness, options) {
119
122
  const command = hookCommand(adapterPath, 'stop', harness, options);
120
123
  if (stopHandlerIsDetached(harness, options.platform)) {
121
124
  return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true };
122
125
  }
123
126
  if (harness === 'codex' || harness === 'claude-code') {
124
- return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS };
127
+ return {
128
+ type: 'command',
129
+ command,
130
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
131
+ statusMessage: BLOCKING_STOP_STATUS_MESSAGE,
132
+ };
125
133
  }
126
134
  return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS };
127
135
  }
@@ -134,97 +142,44 @@ function commandOptionsFrom(options) {
134
142
  export function buildHookConfig(options) {
135
143
  const { adapterPath } = options;
136
144
  const commandOptions = commandOptionsFrom(options);
137
- return {
138
- SessionStart: [
139
- {
140
- hooks: [
141
- {
142
- type: 'command',
143
- command: hookCommand(adapterPath, 'session-start', options.harness, commandOptions),
144
- // Activation is local context only: no config, credentials, or network.
145
- timeout: 5,
146
- },
147
- ],
148
- },
149
- ],
150
- SubagentStart: [
151
- {
152
- hooks: [
153
- {
154
- type: 'command',
155
- command: hookCommand(adapterPath, 'subagent-start', options.harness, commandOptions),
156
- timeout: 5,
157
- },
158
- ],
159
- },
160
- ],
161
- UserPromptSubmit: [
162
- {
163
- hooks: [
164
- {
165
- type: 'command',
166
- command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
167
- // Claude Code defaults UserPromptSubmit to 30s. Choose a shorter
168
- // explicit budget so a slow network cannot delay the user's prompt.
169
- timeout: 15,
170
- },
171
- ],
172
- },
173
- ],
174
- Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
175
- SessionEnd: [
145
+ const hooks = Object.create(null);
146
+ for (const row of HOOK_EVENT_TABLE) {
147
+ if (row.document === null)
148
+ continue;
149
+ if (row.notifai === 'stop') {
150
+ hooks[row.document] = [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }];
151
+ continue;
152
+ }
153
+ hooks[row.document] = [
176
154
  {
177
155
  hooks: [
178
156
  {
179
157
  type: 'command',
180
- command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
181
- // Both harnesses give SessionEnd a ~1-3s budget, so this handler
182
- // only touches local state.
183
- timeout: 3,
158
+ command: hookCommand(adapterPath, row.notifai, options.harness, commandOptions),
159
+ timeout: row.timeoutSeconds,
184
160
  },
185
161
  ],
186
162
  },
187
- ],
188
- };
163
+ ];
164
+ }
165
+ return hooks;
189
166
  }
190
167
  /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
191
168
  export function buildCursorHookConfig(options) {
192
169
  const commandOptions = commandOptionsFrom(options);
193
- return {
194
- sessionStart: [
195
- {
196
- command: hookCommand(options.adapterPath, 'session-start', 'cursor', commandOptions),
197
- // Activation only emits local context and must not depend on setup.
198
- timeout: 5,
199
- },
200
- ],
201
- beforeSubmitPrompt: [
202
- {
203
- command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor', commandOptions),
204
- timeout: 15,
205
- },
206
- ],
207
- stop: [
208
- {
209
- command: hookCommand(options.adapterPath, 'activation-stop', 'cursor', commandOptions),
210
- timeout: 5,
211
- loop_limit: 1,
212
- },
213
- {
214
- command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
215
- timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
216
- // A continuation may register a real follow-up question. Match the
217
- // session-state cap so those chains are useful but never unbounded.
218
- loop_limit: 3,
219
- },
220
- ],
221
- sessionEnd: [
222
- {
223
- command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
224
- timeout: 3,
225
- },
226
- ],
227
- };
170
+ const hooks = Object.create(null);
171
+ for (const row of HOOK_EVENT_TABLE) {
172
+ if (row.cursor === null)
173
+ continue;
174
+ const handler = {
175
+ command: hookCommand(options.adapterPath, row.notifai, 'cursor', commandOptions),
176
+ timeout: row.notifai === 'stop' ? NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS : row.timeoutSeconds,
177
+ };
178
+ if ('cursorLoopLimit' in row)
179
+ handler.loop_limit = row.cursorLoopLimit;
180
+ hooks[row.cursor] = [...(hooks[row.cursor] ?? []), handler];
181
+ }
182
+ return hooks;
228
183
  }
229
184
  /**
230
185
  * The one file each harness reads Notifai's lifecycle wiring from: the current
@@ -441,10 +396,7 @@ export function codexLegacyProjectLayers(cwd) {
441
396
  return dirs.map(codexLayerPathsIn);
442
397
  }
443
398
  /**
444
- * Where Notifai writes in this Codex layer. An existing single Notifai
445
- * representation is stable: upgrades refresh it in place because Codex keys
446
- * approval by both handler identity and source path. Only a fresh installation
447
- * chooses between `hooks.json` and inline `config.toml`.
399
+ * Where Notifai writes in this Codex layer.
448
400
  *
449
401
  * `config.toml` is the User's whole Codex configuration and Codex rewrites it
450
402
  * itself for `[hooks.state]`; keeping Notifai out of it bounds the blast radius
@@ -454,9 +406,12 @@ export function codexLegacyProjectLayers(cwd) {
454
406
  * exists only so Codex never prints its dual-representation warning.
455
407
  *
456
408
  * A fresh installation uses `hooks.json` unless foreign inline handlers make
457
- * `config.toml` the layer's established representation. `[hooks.state]` is the
458
- * trust store rather than a hook definition, so it never counts as a
459
- * representation and is preserved through every write.
409
+ * `config.toml` the layer's established representation. Notifai-owned inline
410
+ * handlers with no foreign inline neighbours are moved to `hooks.json`. Codex
411
+ * keys approval by source path, so that move is a User-owned `/hooks` review,
412
+ * not a silent identity-preserving refresh. `[hooks.state]` is the trust store
413
+ * rather than a hook definition, so it never counts as a representation and is
414
+ * preserved through every write.
460
415
  */
461
416
  export function inspectCodexLayer(paths) {
462
417
  const jsonDocument = tryLoadSettings(paths.hooksJson);
@@ -472,13 +427,9 @@ export function inspectCodexLayer(paths) {
472
427
  ourJsonEvents,
473
428
  ourTomlEvents,
474
429
  foreignTomlHooks,
475
- writeTarget: ourTomlEvents.length > 0 && ourJsonEvents.length === 0
430
+ writeTarget: foreignTomlHooks && !(ourJsonEvents.length > 0 && ourTomlEvents.length === 0)
476
431
  ? paths.configToml
477
- : ourJsonEvents.length > 0 && ourTomlEvents.length === 0
478
- ? paths.hooksJson
479
- : foreignTomlHooks
480
- ? paths.configToml
481
- : paths.hooksJson,
432
+ : paths.hooksJson,
482
433
  };
483
434
  }
484
435
  /**
@@ -629,7 +580,7 @@ function globalHarnessEvidence(env = process.env, platform = process.platform) {
629
580
  found.push('codex');
630
581
  if (existsSync(path.join(home, '.cursor')))
631
582
  found.push('cursor');
632
- if (existsSync(path.join(home, '.config', 'opencode')))
583
+ if (existsSync(opencodeConfigDir(env, platform)))
633
584
  found.push('opencode');
634
585
  if (openclawHasGlobalEvidence(existsSync, env, platform))
635
586
  found.push('openclaw');
@@ -689,13 +640,13 @@ function isOurCommand(command, scriptPath) {
689
640
  }
690
641
  /** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
691
642
  function isLegacyNotifaiCommand(command) {
692
- const hook = `['"]?\\s+hook (?:session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\\b`;
643
+ const hook = `['"]?\\s+hook (?:${HOOK_EVENT_PATTERN})\\b`;
693
644
  return (new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
694
645
  new RegExp(`(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`, 'i').test(command) ||
695
646
  new RegExp(`[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`, 'i').test(command));
696
647
  }
697
648
  export function mergeCursorHooks(existing, incoming, scriptPath) {
698
- const hooks = {};
649
+ const hooks = Object.create(null);
699
650
  const added = [];
700
651
  const replaced = [];
701
652
  const removed = [];
@@ -718,7 +669,7 @@ export function mergeCursorHooks(existing, incoming, scriptPath) {
718
669
  return { document: { ...existing, version: 1, hooks }, added, replaced, removed };
719
670
  }
720
671
  export function removeCursorHooks(existing, scriptPath) {
721
- const hooks = {};
672
+ const hooks = Object.create(null);
722
673
  const replaced = [];
723
674
  for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
724
675
  const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath));
@@ -786,7 +737,7 @@ function withoutOurs(groups, scriptPath) {
786
737
  * failure that reinstalling could not clear.
787
738
  */
788
739
  export function mergeHooks(existing, incoming, scriptPath) {
789
- const hooks = {};
740
+ const hooks = Object.create(null);
790
741
  const added = [];
791
742
  const replaced = [];
792
743
  const removed = [];
@@ -815,7 +766,7 @@ export function mergeHooks(existing, incoming, scriptPath) {
815
766
  return { document: { ...existing, hooks }, added, replaced, removed };
816
767
  }
817
768
  export function removeHooks(existing, scriptPath) {
818
- const hooks = {};
769
+ const hooks = Object.create(null);
819
770
  const replaced = [];
820
771
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
821
772
  if (!Array.isArray(groups)) {
@@ -848,30 +799,38 @@ export function removeHooks(existing, scriptPath) {
848
799
  * support.
849
800
  */
850
801
  export function applyPlan(file, document) {
802
+ preparePlan(file, document)();
803
+ }
804
+ /** Validate a proposed document before any file in a multi-file edit changes. */
805
+ export function preparePlan(file, document, previous = readOptionalOwnedRegularFile(file)) {
851
806
  const body = isTomlSettingsPath(file)
852
- ? tomlBody(file, document)
807
+ ? tomlBody(file, document, previous)
853
808
  : `${JSON.stringify(document, null, 2)}\n`;
854
- // A document with nothing left in it is a file Notifai created and has just
855
- // emptied. Keeping it would leave `{}` behind as the visible residue of an
856
- // uninstall; a file that still holds anything of the User's is never empty.
857
- if ((!isTomlSettingsPath(file) && isEmptyJsonDocument(body)) ||
858
- (isTomlSettingsPath(file) && body.trim() === '')) {
859
- if (existsSync(file))
860
- rmSync(file, { force: true });
861
- return;
809
+ const empty = isTomlSettingsPath(file) ? body.trim() === '' : isEmptyJsonDocument(body);
810
+ return prepareFileWrite(file, previous, empty ? null : body);
811
+ }
812
+ function readOptionalOwnedRegularFile(file) {
813
+ try {
814
+ return readOwnedRegularFile(file);
862
815
  }
863
- if (existsSync(file)) {
864
- try {
865
- if (readOwnedRegularFile(file) === body)
866
- return;
867
- }
868
- catch {
869
- // A file we cannot re-read still needs the replacement below.
870
- }
816
+ catch (err) {
817
+ if (err.code === 'ENOENT')
818
+ return null;
819
+ throw err;
871
820
  }
872
- atomicWriteFileSync(file, body, {
873
- requireCurrentUserOwner: true,
874
- });
821
+ }
822
+ function prepareFileWrite(file, previous, next) {
823
+ return () => {
824
+ const current = readOptionalOwnedRegularFile(file);
825
+ if (current !== previous)
826
+ throw new Error(`Configuration changed while preparing ${file}; retry.`);
827
+ if (current === next)
828
+ return;
829
+ if (next === null)
830
+ rmSync(file, { force: true });
831
+ else
832
+ atomicWriteFileSync(file, next, { requireCurrentUserOwner: true });
833
+ };
875
834
  }
876
835
  function isEmptyJsonDocument(body) {
877
836
  try {
@@ -921,73 +880,136 @@ export function cleanupEmptiedCodexLayer(paths) {
921
880
  * came in as, and only the hooks tables are regenerated — re-emitted at the end
922
881
  * of the file, because TOML does not care where a table sits and splicing into
923
882
  * the middle would mean reasoning about a region line by line. If the result
924
- * cannot be proven to parse back to the document asked for, this falls back to
925
- * the whole-file rewrite rather than risk a file it half-understood.
883
+ * cannot be proven to parse back to the document asked for, refuse the write.
926
884
  */
927
- function tomlBody(file, document) {
928
- const whole = `${stringifyToml(document)}\n`;
929
- if (!existsSync(file))
930
- return whole;
931
- let previous;
932
- try {
933
- previous = readOwnedRegularFile(file);
934
- }
935
- catch {
936
- return whole;
885
+ function tomlBody(file, document, previous) {
886
+ if (previous === null)
887
+ return `${stringifyToml(document)}\n`;
888
+ if (sameTomlValue(parseToml(previous), document))
889
+ return previous;
890
+ const next = spliceTomlHooks(previous, document);
891
+ if (next === null)
892
+ throw new Error(`Cannot preserve unrelated TOML configuration in ${file}; no changes written.`);
893
+ return next;
894
+ }
895
+ /** Only locate syntax; smol-toml owns validity and decoded key identity. */
896
+ function tomlStatements(source) {
897
+ parseToml(source);
898
+ const statements = [];
899
+ let offset = 0;
900
+ while (offset < source.length) {
901
+ if (/\s/.test(source[offset])) {
902
+ offset++;
903
+ continue;
904
+ }
905
+ if (source[offset] === '#') {
906
+ while (offset < source.length && source[offset] !== '\n')
907
+ offset++;
908
+ continue;
909
+ }
910
+ const start = offset;
911
+ let depth = 0;
912
+ let equals = -1;
913
+ const comments = [];
914
+ while (offset < source.length) {
915
+ const char = source[offset];
916
+ if (char === '"' || char === "'") {
917
+ const multiline = source.slice(offset, offset + 3) === char.repeat(3);
918
+ offset += multiline ? 3 : 1;
919
+ while (offset < source.length) {
920
+ if (char === '"' && source[offset] === '\\') {
921
+ offset += 2;
922
+ continue;
923
+ }
924
+ if (source[offset] === char) {
925
+ let count = 1;
926
+ while (source[offset + count] === char)
927
+ count++;
928
+ if (!multiline || count >= 3) {
929
+ offset += multiline ? count : 1;
930
+ break;
931
+ }
932
+ offset += count;
933
+ }
934
+ else
935
+ offset++;
936
+ }
937
+ continue;
938
+ }
939
+ if (char === '#') {
940
+ if (depth === 0)
941
+ break;
942
+ const commentStart = offset;
943
+ while (offset < source.length && source[offset] !== '\n')
944
+ offset++;
945
+ comments.push({ start: commentStart, end: offset });
946
+ continue;
947
+ }
948
+ if (char === '\n' && depth === 0)
949
+ break;
950
+ if (char === '[' || char === '{')
951
+ depth++;
952
+ if (char === ']' || char === '}')
953
+ depth--;
954
+ if (char === '=' && depth === 0 && equals === -1)
955
+ equals = offset;
956
+ offset++;
957
+ }
958
+ const table = source[start] === '[';
959
+ const syntax = source.slice(start, offset).trim();
960
+ // A standalone table or a dummy assignment decodes dotted/quoted/escaped
961
+ // keys without interpreting brackets, dots or quotes inside a key as syntax.
962
+ let decoded = parseToml(table ? syntax : `${source.slice(start, equals)} = 0`);
963
+ const keys = [];
964
+ while (decoded !== null && typeof decoded === 'object') {
965
+ if (Array.isArray(decoded)) {
966
+ decoded = decoded[0];
967
+ continue;
968
+ }
969
+ const entries = Object.entries(decoded);
970
+ if (entries.length !== 1)
971
+ break;
972
+ const [key, value] = entries[0];
973
+ keys.push(key);
974
+ decoded = value;
975
+ }
976
+ statements.push({ start, end: offset, table, path: keys, comments });
937
977
  }
938
- return spliceTomlHooks(previous, document) ?? whole;
939
- }
940
- /** A TOML table header line, e.g. `[hooks.state."…"]` or `[[hooks.Stop]]`. */
941
- const TOML_TABLE_HEADER = /^\s*\[\[?([^[\]]+)\]\]?\s*(?:#.*)?$/;
942
- /**
943
- * The first key in a dotted TOML key path, unquoting it if it is quoted.
944
- * `hooks.state."/a/b:stop:0:0"` is rooted at `hooks`, and so is `hooks.Stop`.
945
- */
946
- function firstTomlKey(keyPath) {
947
- const trimmed = keyPath.trim();
948
- const quote = trimmed[0];
949
- if (quote === '"' || quote === "'") {
950
- const end = trimmed.indexOf(quote, 1);
951
- return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end);
978
+ return statements;
979
+ }
980
+ /** Splice syntax spans only; even comments inside removed arrays survive. */
981
+ function omitTomlStatements(source, removed) {
982
+ let next = '';
983
+ let offset = 0;
984
+ for (const statement of removed) {
985
+ next += source.slice(offset, statement.start);
986
+ for (const comment of statement.comments)
987
+ next += `${source.slice(comment.start, comment.end)}\n`;
988
+ offset = statement.end;
952
989
  }
953
- const dot = trimmed.indexOf('.');
954
- return (dot === -1 ? trimmed : trimmed.slice(0, dot)).trim();
990
+ return next + source.slice(offset);
955
991
  }
956
- /**
957
- * `source` with its hooks tables replaced by `document`'s, or null when that
958
- * cannot be done safely — an inline top-level `hooks` key, or a result that
959
- * does not parse back to exactly the document asked for.
960
- */
992
+ /** Replace hook syntax only, validating all unrelated parsed values. */
961
993
  function spliceTomlHooks(source, document) {
962
- const kept = [];
963
- let inHooks = false;
964
- for (const line of source.split('\n')) {
965
- const header = TOML_TABLE_HEADER.exec(line);
966
- if (header !== null)
967
- inHooks = firstTomlKey(header[1] ?? '') === 'hooks';
968
- // A root-level `hooks = …` or `hooks.x = …` would survive the splice and
969
- // then collide with the tables appended below.
970
- if (!inHooks && /^\s*hooks\s*[.=]/.test(line))
971
- return null;
972
- if (!inHooks)
973
- kept.push(line);
974
- }
975
- const hooks = document.hooks;
976
- const body = hooks === undefined ? '' : stringifyToml({ hooks });
977
- const head = kept.join('\n').trimEnd();
978
- const spliced = body === '' ? `${head}\n` : head === '' ? `${body}\n` : `${head}\n\n${body}\n`;
979
994
  try {
980
- if (!sameTomlValue(parseToml(spliced), document))
981
- return null;
995
+ let table = [];
996
+ const removed = tomlStatements(source).filter((statement) => {
997
+ if (statement.table)
998
+ table = statement.path;
999
+ return (statement.table ? table : [...table, ...statement.path])[0] === 'hooks';
1000
+ });
1001
+ const head = omitTomlStatements(source, removed);
1002
+ const body = document.hooks === undefined ? '' : stringifyToml({ hooks: document.hooks });
1003
+ const spliced = body === '' ? head : `${head}${head === '' || head.endsWith('\n') ? '' : '\n'}${body}\n`;
1004
+ return sameTomlValue(parseToml(spliced), document) ? spliced : null;
982
1005
  }
983
1006
  catch {
984
1007
  return null;
985
1008
  }
986
- return spliced;
987
1009
  }
988
1010
  /** Structural equality for parsed TOML, which carries dates as well as data. */
989
1011
  function sameTomlValue(a, b) {
990
- if (a === b)
1012
+ if (Object.is(a, b))
991
1013
  return true;
992
1014
  if (a instanceof Date && b instanceof Date)
993
1015
  return a.getTime() === b.getTime();
@@ -1003,12 +1025,12 @@ function sameTomlValue(a, b) {
1003
1025
  const keys = Object.keys(left);
1004
1026
  if (keys.length !== Object.keys(right).length)
1005
1027
  return false;
1006
- return keys.every((key) => key in right && sameTomlValue(left[key], right[key]));
1028
+ return keys.every((key) => Object.hasOwn(right, key) && sameTomlValue(left[key], right[key]));
1007
1029
  }
1008
1030
  export function loadSettings(file) {
1009
1031
  return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file);
1010
1032
  }
1011
- function isTomlSettingsPath(file) {
1033
+ export function isTomlSettingsPath(file) {
1012
1034
  return file.endsWith('.toml');
1013
1035
  }
1014
1036
  function readTomlSettings(file) {
@@ -1164,7 +1186,139 @@ export function codexTrustProblems(installations, env = process.env) {
1164
1186
  * evidence that it has (and is itself worth reporting).
1165
1187
  */
1166
1188
  function isNotifaiCommand(command) {
1167
- return / hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(command);
1189
+ return HOOK_EVENT_COMMAND_RE.test(command);
1190
+ }
1191
+ /**
1192
+ * Native plugin ids that would fire beside document hooks if left enabled.
1193
+ *
1194
+ * Notifai no longer wires through harness plugins. A leftover enablement from
1195
+ * a trial or an older experiment still runs the same adapter command, so
1196
+ * document handlers plus that plugin fire every event twice. Matching is the
1197
+ * plugin name `notifai` with an optional `@marketplace` suffix — never a
1198
+ * substring of someone else's plugin id.
1199
+ */
1200
+ function isNotifaiNativePluginKey(key) {
1201
+ const id = key.split('@')[0]?.trim() ?? '';
1202
+ return id.toLowerCase() === 'notifai';
1203
+ }
1204
+ function pluginEntryIsEnabled(value) {
1205
+ if (value === false)
1206
+ return false;
1207
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
1208
+ return value['enabled'] !== false;
1209
+ }
1210
+ return true;
1211
+ }
1212
+ export function notifaiNativePluginEnablementKeys(value) {
1213
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
1214
+ return [];
1215
+ return Object.entries(value)
1216
+ .filter(([key, enabled]) => isNotifaiNativePluginKey(key) && pluginEntryIsEnabled(enabled))
1217
+ .map(([key]) => key);
1218
+ }
1219
+ export function stripObsoleteNotifaiPluginEnablement(document) {
1220
+ const next = { ...document };
1221
+ const removed = [];
1222
+ for (const field of ['enabledPlugins', 'plugins']) {
1223
+ const value = next[field];
1224
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
1225
+ continue;
1226
+ const record = { ...value };
1227
+ let changed = false;
1228
+ for (const key of Object.keys(record)) {
1229
+ if (!isNotifaiNativePluginKey(key) || !pluginEntryIsEnabled(record[key]))
1230
+ continue;
1231
+ delete record[key];
1232
+ removed.push(key);
1233
+ changed = true;
1234
+ }
1235
+ if (!changed)
1236
+ continue;
1237
+ if (Object.keys(record).length === 0)
1238
+ delete next[field];
1239
+ else
1240
+ next[field] = record;
1241
+ }
1242
+ return { document: next, removed };
1243
+ }
1244
+ /**
1245
+ * Remove enabled Notifai plugin tables only after lexical ownership and full
1246
+ * semantic preservation are proven. Unsupported dotted/inline definitions
1247
+ * fail before mutation, rather than silently leaving a second firing path.
1248
+ */
1249
+ export function spliceOutNotifaiPluginTables(source) {
1250
+ const expected = parseToml(source);
1251
+ const plugins = expected['plugins'];
1252
+ const removed = notifaiNativePluginEnablementKeys(plugins);
1253
+ if (removed.length === 0)
1254
+ return { next: source, removed };
1255
+ const remaining = { ...plugins };
1256
+ for (const key of removed)
1257
+ delete remaining[key];
1258
+ expected['plugins'] = remaining;
1259
+ let dropping = false;
1260
+ const omitted = tomlStatements(source).filter((statement) => {
1261
+ if (statement.table) {
1262
+ dropping = statement.path[0] === 'plugins' && removed.includes(statement.path[1] ?? '');
1263
+ }
1264
+ return dropping;
1265
+ });
1266
+ const next = omitTomlStatements(source, omitted);
1267
+ const parsed = parseToml(next);
1268
+ // Removing the last child table may also remove its implicit parent.
1269
+ for (const value of [expected, parsed]) {
1270
+ const entries = value['plugins'];
1271
+ if (entries && typeof entries === 'object' && !Array.isArray(entries) && Object.keys(entries).length === 0) {
1272
+ delete value['plugins'];
1273
+ }
1274
+ }
1275
+ if (!sameTomlValue(parsed, expected)) {
1276
+ throw new Error('Cannot safely remove obsolete native plugin wiring from this TOML form; disable the Notifai plugin or use [plugins."notifai"] table form and retry. No changes written.');
1277
+ }
1278
+ return { next, removed };
1279
+ }
1280
+ /**
1281
+ * Leftover native plugin enablement that would fire beside document hooks.
1282
+ *
1283
+ * Reads only harness settings files this installer already owns inspecting.
1284
+ * It never shells out to a plugin CLI and never writes a plugin.
1285
+ */
1286
+ export function findObsoleteNotifaiPluginWiring(env = process.env, platform = process.platform) {
1287
+ const found = [];
1288
+ const claudeFile = settingsFile('claude-code', env, platform);
1289
+ if (existsSync(claudeFile)) {
1290
+ try {
1291
+ const keys = notifaiNativePluginEnablementKeys(loadSettings(claudeFile)['enabledPlugins']);
1292
+ if (keys.length > 0)
1293
+ found.push({ harness: 'claude-code', file: claudeFile, keys });
1294
+ }
1295
+ catch {
1296
+ // Unreadable settings are reported by the ordinary install/doctor path.
1297
+ }
1298
+ }
1299
+ const codexToml = codexMachineLayerPaths(env, platform).configToml;
1300
+ if (existsSync(codexToml)) {
1301
+ try {
1302
+ const keys = notifaiNativePluginEnablementKeys(loadSettings(codexToml)['plugins']);
1303
+ if (keys.length > 0)
1304
+ found.push({ harness: 'codex', file: codexToml, keys });
1305
+ }
1306
+ catch {
1307
+ // Same: leave diagnosis to the settings reader that already ran.
1308
+ }
1309
+ }
1310
+ return found;
1311
+ }
1312
+ /**
1313
+ * Plan native plugin retirement without changing configuration. The caller
1314
+ * validates every destination before applying this plan under the layer lock.
1315
+ */
1316
+ export function prepareCodexPluginCleanup(file) {
1317
+ const previous = readOptionalOwnedRegularFile(file);
1318
+ if (previous === null)
1319
+ return { source: null, removed: [], apply: prepareFileWrite(file, null, null) };
1320
+ const { next, removed } = spliceOutNotifaiPluginTables(previous);
1321
+ return { source: next, removed, apply: prepareFileWrite(file, previous, next) };
1168
1322
  }
1169
1323
  /**
1170
1324
  * The one Machine installation each harness would read a Notifai handler from.
@@ -1334,7 +1488,7 @@ export function handlerEvent(command) {
1334
1488
  function readOwnedRegularFile(file) {
1335
1489
  const stat = lstatSync(file);
1336
1490
  if (stat.isSymbolicLink() || !stat.isFile()) {
1337
- throw new Error(`${file} is not a regular file; refusing to read it.`);
1491
+ throw new Error(`${file} is not a regular file${stat.isSymbolicLink() ? ' (symlink)' : ''}; refusing to read it.`);
1338
1492
  }
1339
1493
  const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
1340
1494
  if (uid !== undefined && stat.uid !== uid) {