@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.
- package/CHANGELOG.md +49 -0
- package/README.md +6 -0
- package/dist/agent-update-notice.d.ts +7 -0
- package/dist/agent-update-notice.js +77 -0
- package/dist/agent-update-notice.js.map +1 -0
- package/dist/cli-release.d.ts +3 -3
- package/dist/cli-release.js +12 -16
- package/dist/cli-release.js.map +1 -1
- package/dist/codex-wake.d.ts +44 -63
- package/dist/codex-wake.js +71 -174
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-ask.js +3 -1
- package/dist/commands-ask.js.map +1 -1
- package/dist/commands-close.js +28 -14
- package/dist/commands-close.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-doctor.js +36 -19
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +1 -1
- package/dist/commands-guidance.js +15 -2
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hook-install.js +146 -54
- package/dist/commands-hook-install.js.map +1 -1
- package/dist/commands-hook-run.js +21 -13
- package/dist/commands-hook-run.js.map +1 -1
- package/dist/commands-hook-shape.js +3 -2
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-update-check.d.ts +6 -0
- package/dist/commands-update-check.js +86 -0
- package/dist/commands-update-check.js.map +1 -0
- package/dist/commands-update-skill.d.ts +5 -0
- package/dist/commands-update-skill.js +36 -0
- package/dist/commands-update-skill.js.map +1 -0
- package/dist/commands-update.js +29 -1
- package/dist/commands-update.js.map +1 -1
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/commands.js.map +1 -1
- package/dist/credentials.js +7 -4
- package/dist/credentials.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js +2 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +7 -1
- package/dist/hook-adapter.js +36 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hook-events.d.ts +69 -2
- package/dist/hook-events.js +75 -13
- package/dist/hook-events.js.map +1 -1
- package/dist/hook-lifecycle.js +44 -15
- package/dist/hook-lifecycle.js.map +1 -1
- package/dist/hook-question-retirement.d.ts +9 -0
- package/dist/hook-question-retirement.js +38 -3
- package/dist/hook-question-retirement.js.map +1 -1
- package/dist/hook-question-state.d.ts +14 -5
- package/dist/hook-question-state.js +55 -24
- package/dist/hook-question-state.js.map +1 -1
- package/dist/hook-types.d.ts +6 -0
- package/dist/install-hooks.d.ts +70 -12
- package/dist/install-hooks.js +352 -198
- package/dist/install-hooks.js.map +1 -1
- package/dist/native-skills.d.ts +2 -0
- package/dist/native-skills.js +3 -1
- package/dist/native-skills.js.map +1 -1
- package/dist/openclaw-plugin.d.ts +0 -1
- package/dist/openclaw-plugin.js +0 -7
- package/dist/openclaw-plugin.js.map +1 -1
- package/dist/opencode-plugin.d.ts +9 -0
- package/dist/opencode-plugin.js +19 -2
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +3 -1
- package/dist/program.js +21 -6
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +9 -1
- package/dist/readiness.js +11 -3
- package/dist/readiness.js.map +1 -1
- package/dist/session-activation.d.ts +2 -2
- package/dist/session-activation.js +8 -7
- package/dist/session-activation.js.map +1 -1
- package/dist/session-labels.js +6 -6
- package/dist/session-labels.js.map +1 -1
- package/dist/skill-source/manifest.json +8 -4
- package/dist/skill-source/notifai/SKILL.md +14 -16
- package/dist/skill-source/notifai/references/harness-setup.md +23 -14
- package/dist/skill-source/notifai/references/updates.md +74 -0
- package/dist/ui/theme.d.ts +1 -1
- package/dist/update-handoff.d.ts +29 -0
- package/dist/update-handoff.js +66 -0
- package/dist/update-handoff.js.map +1 -0
- package/package.json +2 -2
package/dist/install-hooks.js
CHANGED
|
@@ -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 {
|
|
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
|
|
102
|
-
*
|
|
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
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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 {
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
181
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
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.
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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:
|
|
430
|
+
writeTarget: foreignTomlHooks && !(ourJsonEvents.length > 0 && ourTomlEvents.length === 0)
|
|
476
431
|
? paths.configToml
|
|
477
|
-
:
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
873
|
-
|
|
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,
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
939
|
-
}
|
|
940
|
-
/**
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
|
|
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
|
|
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
|
|
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
|
|
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) {
|