@raidiant/notifai 8.0.0 → 9.0.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 +41 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +231 -85
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/dist/commands-hooks.js
CHANGED
|
@@ -9,28 +9,45 @@ import { loadConfig } from './config.js';
|
|
|
9
9
|
import { withTargetFileLock } from './file-lock.js';
|
|
10
10
|
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
|
|
11
11
|
import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
|
|
12
|
-
import { clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readSessionState, registerQuestion, waiterCeilingSeconds, } from './hooks.js';
|
|
13
|
-
import { BLOCKING_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexCoexistenceNotes, codexHomeNote, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, stopHandlerIsDetached, withCodexLayerTransaction, } from './install-hooks.js';
|
|
12
|
+
import { clearAcknowledgementObligation, claimPromptActivation, claimCursorStopActivation, confirmCursorStopActivation, dropPendingQuestion, handleSessionEnd, handleStop, handleUserPromptSubmit, markSessionActivation, parkForRetirement, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readSessionState, resetCursorStopActivation, registerQuestion, waiterCeilingSeconds, withdrawUnpushedQuestions, } from './hooks.js';
|
|
13
|
+
import { BLOCKING_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, cleanupEmptiedCodexLayer, codexCoexistenceNotes, codexHomeNote, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, stopHandlerIsDetached, withCodexLayerTransaction, } from './install-hooks.js';
|
|
14
14
|
import { logConfigResolved, logSettingsFrom } from './logging.js';
|
|
15
15
|
import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js';
|
|
16
16
|
import { packageVersion } from './release.js';
|
|
17
|
+
import { rejectAccidentalEscapedNewlines } from './send.js';
|
|
18
|
+
import { cursorStopActivationOutput, sessionActivationOutput, } from './session-activation.js';
|
|
17
19
|
import { CHOICE_USAGE, buildDraft, parseChoices, slugify, validateMediaInputs, } from './send.js';
|
|
18
|
-
import { EXIT, authedClient, loadLoggedConfig, log, makeClient, rejectedPaths, reportError, } from './commands-core.js';
|
|
20
|
+
import { EXIT, authedClient, diagnoseIgnoredOriginOverride, loadLoggedConfig, log, makeClient, rejectedPaths, reportError, } from './commands-core.js';
|
|
19
21
|
import { claudeSessionPid, resolveActiveHarness, } from './commands-harness-context.js';
|
|
20
22
|
import { stopShapeProblems } from './commands-hook-shape.js';
|
|
21
23
|
import { acknowledgementCommand, printAcknowledgementStatus, resolveDraftInvocation, uploadImage, waitForReply, } from './commands-send-support.js';
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
23
25
|
// hook / ask / close — harness integration
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
|
-
export const HOOK_EVENTS = [
|
|
27
|
+
export const HOOK_EVENTS = [
|
|
28
|
+
'session-start',
|
|
29
|
+
'subagent-start',
|
|
30
|
+
'activation-stop',
|
|
31
|
+
'user-prompt-submit',
|
|
32
|
+
'stop',
|
|
33
|
+
'session-end',
|
|
34
|
+
];
|
|
35
|
+
/** Lifecycle handlers one installed harness must carry in this CLI build. */
|
|
36
|
+
export function requiredHookEvents(harness) {
|
|
37
|
+
if (harness === 'opencode')
|
|
38
|
+
return [];
|
|
39
|
+
return harness === 'cursor'
|
|
40
|
+
? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
|
|
41
|
+
: ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end'];
|
|
42
|
+
}
|
|
26
43
|
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
27
44
|
export function hookDefersDiagnosticsUntilAfterCleanup(event) {
|
|
28
45
|
return event === 'session-end';
|
|
29
46
|
}
|
|
30
47
|
/**
|
|
31
48
|
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
32
|
-
* stdin,
|
|
33
|
-
* exit 0 with no stdout means "no decision — carry on as normal".
|
|
49
|
+
* stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
|
|
50
|
+
* exit 0 with no stdout means "no decision or added context — carry on as normal".
|
|
34
51
|
*
|
|
35
52
|
* Every failure path in here must reach that no-decision state. A hook that
|
|
36
53
|
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
@@ -63,6 +80,20 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
63
80
|
const failureData = (err) => err instanceof ApiCallError
|
|
64
81
|
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
65
82
|
: { message: err instanceof Error ? err.message : String(err) };
|
|
83
|
+
// Cursor may load ~/.claude/settings.json in addition to its own native
|
|
84
|
+
// hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
|
|
85
|
+
// compatibility copy becomes a no-op and the native Cursor definition is
|
|
86
|
+
// the single owner. Real Claude hooks do not receive that hook-only marker.
|
|
87
|
+
if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
|
|
88
|
+
start({ outcome: 'cursor-compatibility-copy-skipped' });
|
|
89
|
+
logger.info('hook.end', {
|
|
90
|
+
hook: event,
|
|
91
|
+
outcome: 'ignored',
|
|
92
|
+
reason: 'cursor-native-handler-owns-event',
|
|
93
|
+
decided: false,
|
|
94
|
+
});
|
|
95
|
+
return EXIT.ok;
|
|
96
|
+
}
|
|
66
97
|
let raw;
|
|
67
98
|
try {
|
|
68
99
|
raw = await readStdin();
|
|
@@ -105,6 +136,164 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
105
136
|
const cwd = envelope.cwd ?? deps.cwd;
|
|
106
137
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
|
|
107
138
|
logger.bind({ session: envelope.session_id ?? null });
|
|
139
|
+
const declaredSourcePid = declaredHookSourcePid(deps);
|
|
140
|
+
const activationOwnerPid = harness === 'claude-code'
|
|
141
|
+
? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
|
|
142
|
+
: harness === 'codex'
|
|
143
|
+
? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
|
|
144
|
+
: undefined;
|
|
145
|
+
// Session activation is local model context, not authenticated routing. It
|
|
146
|
+
// must survive the exact first-run states it exists to repair: no config,
|
|
147
|
+
// no credential, no Companion device, and no network.
|
|
148
|
+
if (event === 'session-start' || event === 'subagent-start') {
|
|
149
|
+
start({ cwd, source: envelope.source ?? null });
|
|
150
|
+
if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
|
|
151
|
+
try {
|
|
152
|
+
resetCursorStopActivation(envelope.session_id, deps.env);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
logger.error('hook.end', {
|
|
156
|
+
hook: event,
|
|
157
|
+
outcome: 'reset-failed',
|
|
158
|
+
...failureData(err),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const stdout = sessionActivationOutput(harness, event === 'session-start' ? 'SessionStart' : 'SubagentStart');
|
|
163
|
+
if (stdout !== undefined)
|
|
164
|
+
deps.io.out(stdout);
|
|
165
|
+
if (stdout !== undefined &&
|
|
166
|
+
event === 'session-start' &&
|
|
167
|
+
activationOwnerPid !== undefined &&
|
|
168
|
+
envelope.session_id !== undefined) {
|
|
169
|
+
try {
|
|
170
|
+
// Write stdout first. If the process dies between these operations a
|
|
171
|
+
// later prompt may duplicate context, but it cannot suppress context
|
|
172
|
+
// that the host never received.
|
|
173
|
+
markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
logger.error('hook.end', {
|
|
177
|
+
hook: event,
|
|
178
|
+
outcome: 'mark-failed',
|
|
179
|
+
reason: 'activation-state-failed',
|
|
180
|
+
...failureData(err),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
logger.info('hook.end', {
|
|
185
|
+
hook: event,
|
|
186
|
+
outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
|
|
187
|
+
decided: false,
|
|
188
|
+
});
|
|
189
|
+
return EXIT.ok;
|
|
190
|
+
}
|
|
191
|
+
// SessionStart is the preferred model-visible activation seam. A few
|
|
192
|
+
// managed hosts regenerate older hook definitions that retain
|
|
193
|
+
// UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
|
|
194
|
+
// once per exact session as a compatibility fallback, before config/auth/
|
|
195
|
+
// device/network work, and stay silent when SessionStart already ran.
|
|
196
|
+
let promptActivationStdout;
|
|
197
|
+
if (event === 'user-prompt-submit' &&
|
|
198
|
+
(harness === 'claude-code' || harness === 'codex') &&
|
|
199
|
+
activationOwnerPid !== undefined &&
|
|
200
|
+
envelope.session_id !== undefined) {
|
|
201
|
+
try {
|
|
202
|
+
if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid)) {
|
|
203
|
+
promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit');
|
|
204
|
+
if (promptActivationStdout !== undefined)
|
|
205
|
+
deps.io.out(promptActivationStdout);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
logger.error('hook.end', {
|
|
210
|
+
hook: event,
|
|
211
|
+
outcome: 'claim-failed',
|
|
212
|
+
reason: 'activation-state-failed',
|
|
213
|
+
...failureData(err),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Cursor has a confirmed host bug in which sessionStart.additional_context
|
|
218
|
+
// is accepted but never reaches the model. A native Stop follow-up is the
|
|
219
|
+
// host's guaranteed model-visible channel. Claim it once per conversation,
|
|
220
|
+
// before config/auth/network, and leave the ordinary Stop handler separate
|
|
221
|
+
// so question delivery is never displaced by activation.
|
|
222
|
+
if (event === 'activation-stop') {
|
|
223
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
224
|
+
if (harness !== 'cursor') {
|
|
225
|
+
logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false });
|
|
226
|
+
return EXIT.ok;
|
|
227
|
+
}
|
|
228
|
+
if (envelope.status === 'aborted') {
|
|
229
|
+
logger.info('hook.end', {
|
|
230
|
+
hook: event,
|
|
231
|
+
outcome: 'ignored',
|
|
232
|
+
reason: `turn-${envelope.status}`,
|
|
233
|
+
decided: false,
|
|
234
|
+
});
|
|
235
|
+
return EXIT.ok;
|
|
236
|
+
}
|
|
237
|
+
if (envelope.session_id === undefined) {
|
|
238
|
+
logger.info('hook.end', {
|
|
239
|
+
hook: event,
|
|
240
|
+
outcome: 'ignored',
|
|
241
|
+
reason: 'missing-conversation-id',
|
|
242
|
+
decided: false,
|
|
243
|
+
});
|
|
244
|
+
return EXIT.ok;
|
|
245
|
+
}
|
|
246
|
+
if (envelope.session_id !== undefined &&
|
|
247
|
+
typeof envelope.loop_count === 'number' &&
|
|
248
|
+
envelope.loop_count > 0) {
|
|
249
|
+
let activationOwned = false;
|
|
250
|
+
try {
|
|
251
|
+
activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now());
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
logger.error('hook.end', {
|
|
255
|
+
hook: event,
|
|
256
|
+
outcome: 'confirm-failed',
|
|
257
|
+
...failureData(err),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (activationOwned) {
|
|
261
|
+
logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false });
|
|
262
|
+
return EXIT.ok;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const cursorState = readSessionState(envelope.session_id, deps.env);
|
|
266
|
+
if ((cursorState.pending?.length ?? 0) > 0 ||
|
|
267
|
+
cursorState.accepted !== undefined ||
|
|
268
|
+
(cursorState.acknowledgement_due?.length ?? 0) > 0) {
|
|
269
|
+
logger.info('hook.end', {
|
|
270
|
+
hook: event,
|
|
271
|
+
outcome: 'deferred',
|
|
272
|
+
reason: 'question-continuation-owns-stop',
|
|
273
|
+
decided: false,
|
|
274
|
+
});
|
|
275
|
+
return EXIT.ok;
|
|
276
|
+
}
|
|
277
|
+
let claimed = false;
|
|
278
|
+
try {
|
|
279
|
+
claimed = claimCursorStopActivation(envelope.session_id, deps.env, now());
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
logger.error('hook.end', {
|
|
283
|
+
hook: event,
|
|
284
|
+
outcome: 'claim-failed',
|
|
285
|
+
...failureData(err),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
logger.info('hook.end', {
|
|
289
|
+
hook: event,
|
|
290
|
+
outcome: claimed ? 'followup-added' : 'already-activated',
|
|
291
|
+
decided: claimed,
|
|
292
|
+
});
|
|
293
|
+
if (claimed)
|
|
294
|
+
deps.io.out(cursorStopActivationOutput());
|
|
295
|
+
return EXIT.ok;
|
|
296
|
+
}
|
|
108
297
|
let config = null;
|
|
109
298
|
let configFailure;
|
|
110
299
|
try {
|
|
@@ -127,6 +316,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
127
316
|
hook: event,
|
|
128
317
|
outcome: 'failed',
|
|
129
318
|
reason: 'config-failed',
|
|
319
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
130
320
|
...failureData(err),
|
|
131
321
|
});
|
|
132
322
|
for (const line of describeHookFailure(err))
|
|
@@ -174,9 +364,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
174
364
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
175
365
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
176
366
|
const baseUrl = credential.baseUrl;
|
|
177
|
-
|
|
178
|
-
deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
|
|
179
|
-
}
|
|
367
|
+
diagnoseIgnoredOriginOverride(deps.io, resolved, credential);
|
|
180
368
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
181
369
|
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
182
370
|
// Stop is allowed to block and keeps the ordinary budget.
|
|
@@ -222,9 +410,10 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
222
410
|
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
|
|
223
411
|
logger.info('hook.end', {
|
|
224
412
|
hook: event,
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
decided: outcome.stdout !== undefined,
|
|
413
|
+
// Stop stdout takes over the turn. UserPromptSubmit stdout may instead
|
|
414
|
+
// add context to the user's own turn, which is explicitly non-decisive.
|
|
415
|
+
decided: outcome.decided ?? outcome.stdout !== undefined,
|
|
416
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
228
417
|
...(notes.length === 0 ? {} : { notes }),
|
|
229
418
|
...outcome.log,
|
|
230
419
|
});
|
|
@@ -264,13 +453,14 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
264
453
|
function stopWakeRoute(deps, harness, sessionId, cwd) {
|
|
265
454
|
if (sessionId === undefined)
|
|
266
455
|
return undefined;
|
|
456
|
+
const declaredSourcePid = declaredHookSourcePid(deps);
|
|
267
457
|
if (harness === 'claude-code') {
|
|
268
458
|
if ((deps.hookPlatform ?? process.platform) === 'win32')
|
|
269
459
|
return undefined;
|
|
270
460
|
return claudeWakeRoute({
|
|
271
461
|
sessionId,
|
|
272
462
|
cwd,
|
|
273
|
-
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
463
|
+
sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
|
|
274
464
|
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
275
465
|
});
|
|
276
466
|
}
|
|
@@ -278,13 +468,18 @@ function stopWakeRoute(deps, harness, sessionId, cwd) {
|
|
|
278
468
|
return codexWakeRoute({
|
|
279
469
|
threadId: sessionId,
|
|
280
470
|
cwd,
|
|
281
|
-
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
471
|
+
sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
|
|
282
472
|
env: deps.env,
|
|
283
473
|
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
284
474
|
});
|
|
285
475
|
}
|
|
286
476
|
return undefined;
|
|
287
477
|
}
|
|
478
|
+
/** Stable harness parent propagated by the managed adapter across child tools. */
|
|
479
|
+
function declaredHookSourcePid(deps) {
|
|
480
|
+
const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID']);
|
|
481
|
+
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
482
|
+
}
|
|
288
483
|
/**
|
|
289
484
|
* What went wrong, in terms of what to do about it.
|
|
290
485
|
*
|
|
@@ -449,8 +644,9 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
|
|
|
449
644
|
return { ok: true, draft: result.draft };
|
|
450
645
|
}
|
|
451
646
|
function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
647
|
+
let questionId;
|
|
452
648
|
try {
|
|
453
|
-
registerQuestion(sessionId, deps.env, {
|
|
649
|
+
questionId = registerQuestion(sessionId, deps.env, {
|
|
454
650
|
question: built.questions[0].text,
|
|
455
651
|
questions: built.questions,
|
|
456
652
|
body: draft.presentation.body,
|
|
@@ -467,8 +663,9 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
467
663
|
log(deps).info('ask.registered', {
|
|
468
664
|
ok: true,
|
|
469
665
|
session: sessionId,
|
|
666
|
+
question_id: questionId,
|
|
470
667
|
questions: built.questions.length,
|
|
471
|
-
|
|
668
|
+
text_chars: built.questions[0].text.length,
|
|
472
669
|
choices: built.questions[0].choices?.length ?? 0,
|
|
473
670
|
media: draft.presentation.media?.length ?? 0,
|
|
474
671
|
});
|
|
@@ -479,17 +676,20 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
479
676
|
if (json) {
|
|
480
677
|
deps.io.out(JSON.stringify({
|
|
481
678
|
registered: true,
|
|
679
|
+
question_id: questionId,
|
|
482
680
|
questions: built.questions.map((entry) => ({
|
|
483
681
|
id: entry.id,
|
|
484
682
|
text: entry.text,
|
|
485
683
|
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
486
684
|
...(entry.multi === true ? { multi: true } : {}),
|
|
487
685
|
})),
|
|
686
|
+
close: `notifai close ${questionId}`,
|
|
488
687
|
next: {
|
|
489
688
|
end_turn: true,
|
|
490
689
|
in_this_turn: 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
|
|
491
690
|
route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
492
691
|
on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
692
|
+
answered_outside_notifai: `If they answer in the conversation instead, run \`notifai close ${questionId}\` before ending the turn so a later Stop will not send this question.`,
|
|
493
693
|
},
|
|
494
694
|
}, null, 2));
|
|
495
695
|
return EXIT.ok;
|
|
@@ -505,8 +705,8 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
505
705
|
}
|
|
506
706
|
}
|
|
507
707
|
deps.io.out(built.questions.length > 1
|
|
508
|
-
? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
|
|
509
|
-
:
|
|
708
|
+
? `${built.questions.length} questions registered as one form (${questionId}). Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
|
|
709
|
+
: `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`);
|
|
510
710
|
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
511
711
|
for (const [index, entry] of built.questions.entries()) {
|
|
512
712
|
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
@@ -522,6 +722,7 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
522
722
|
}
|
|
523
723
|
deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
|
|
524
724
|
deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
|
|
725
|
+
deps.io.out(`If they answer in this conversation instead, retire it with \`notifai close ${questionId}\` so a later Stop will not send it.`);
|
|
525
726
|
return EXIT.ok;
|
|
526
727
|
}
|
|
527
728
|
async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
|
|
@@ -534,7 +735,7 @@ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation)
|
|
|
534
735
|
mediaIds.push(image);
|
|
535
736
|
continue;
|
|
536
737
|
}
|
|
537
|
-
const uploaded = await uploadImage(deps, authed.client, image);
|
|
738
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value);
|
|
538
739
|
if (!uploaded.ok) {
|
|
539
740
|
if (uploaded.error !== null)
|
|
540
741
|
deps.io.err(uploaded.error);
|
|
@@ -558,6 +759,11 @@ export function askCommand(deps, question, flags) {
|
|
|
558
759
|
// Validate before route discovery. A malformed question belongs to the
|
|
559
760
|
// caller and should not be hidden behind whichever harness setup issue
|
|
560
761
|
// happens to exist on this machine.
|
|
762
|
+
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN);
|
|
763
|
+
if (escapedBody !== null) {
|
|
764
|
+
deps.io.err(escapedBody);
|
|
765
|
+
return EXIT.usage;
|
|
766
|
+
}
|
|
561
767
|
const built = buildQuestions(flags, question);
|
|
562
768
|
if (!built.ok) {
|
|
563
769
|
deps.io.err(built.error);
|
|
@@ -756,19 +962,19 @@ export function hookActivationAdvice(installations) {
|
|
|
756
962
|
const harnesses = new Set(installations.map((installation) => installation.harness));
|
|
757
963
|
const advice = [];
|
|
758
964
|
if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
|
|
759
|
-
advice.push('Claude Code:
|
|
965
|
+
advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`');
|
|
760
966
|
}
|
|
761
967
|
if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
|
|
762
|
-
advice.push('Claude Code global hooks:
|
|
968
|
+
advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`');
|
|
763
969
|
}
|
|
764
970
|
if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
|
|
765
|
-
advice.push('Cursor: send one prompt, then run `notifai doctor
|
|
971
|
+
advice.push('Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
|
|
766
972
|
}
|
|
767
973
|
if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
|
|
768
|
-
advice.push('Cursor global hooks: send one prompt
|
|
974
|
+
advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
|
|
769
975
|
}
|
|
770
976
|
if (harnesses.has('codex')) {
|
|
771
|
-
advice.push('Codex:
|
|
977
|
+
advice.push('Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`');
|
|
772
978
|
}
|
|
773
979
|
if (harnesses.has('opencode')) {
|
|
774
980
|
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
|
|
@@ -797,7 +1003,6 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
797
1003
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
798
1004
|
logger.info('acknowledgement.attempted', {
|
|
799
1005
|
request_id: requestId,
|
|
800
|
-
text,
|
|
801
1006
|
characters: text.length,
|
|
802
1007
|
});
|
|
803
1008
|
const authed = authedClient(deps, config);
|
|
@@ -814,7 +1019,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
814
1019
|
logger.info('acknowledgement.outcome', {
|
|
815
1020
|
request_id: requestId,
|
|
816
1021
|
outcome: result.status,
|
|
817
|
-
|
|
1022
|
+
text_chars: result.agent_acknowledgement.text.length,
|
|
818
1023
|
created_at: result.agent_acknowledgement.created_at,
|
|
819
1024
|
agent_acknowledgement_required: true,
|
|
820
1025
|
});
|
|
@@ -835,12 +1040,27 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
835
1040
|
}
|
|
836
1041
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
837
1042
|
export async function closeCommand(deps, requestId, flags = {}) {
|
|
1043
|
+
if (flags.pending === true && requestId !== undefined) {
|
|
1044
|
+
deps.io.err('Pass a request id or --pending, not both.');
|
|
1045
|
+
return EXIT.usage;
|
|
1046
|
+
}
|
|
1047
|
+
if (flags.pending !== true && requestId === undefined) {
|
|
1048
|
+
deps.io.err('Pass a request id or --pending.');
|
|
1049
|
+
return EXIT.usage;
|
|
1050
|
+
}
|
|
1051
|
+
if (flags.pending === true) {
|
|
1052
|
+
return closePendingQuestions(deps, flags.json === true);
|
|
1053
|
+
}
|
|
1054
|
+
const local = await closeLocalQuestion(deps, requestId, flags.json === true);
|
|
1055
|
+
if (local !== null)
|
|
1056
|
+
return local;
|
|
838
1057
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
839
1058
|
const authed = authedClient(deps, config);
|
|
840
1059
|
if (!authed)
|
|
841
1060
|
return EXIT.auth;
|
|
842
1061
|
try {
|
|
843
1062
|
const response = await authed.client.closeReplies(requestId);
|
|
1063
|
+
forgetClosedQuestion(deps, requestId);
|
|
844
1064
|
if (flags.json) {
|
|
845
1065
|
deps.io.out(JSON.stringify({
|
|
846
1066
|
...response,
|
|
@@ -858,6 +1078,148 @@ export async function closeCommand(deps, requestId, flags = {}) {
|
|
|
858
1078
|
return reportError(deps, err);
|
|
859
1079
|
}
|
|
860
1080
|
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Retire this session's outstanding questions, including registrations the
|
|
1083
|
+
* Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
|
|
1084
|
+
* them is the whole retirement. Live ones still need their reply window closed.
|
|
1085
|
+
*/
|
|
1086
|
+
async function closePendingQuestions(deps, json) {
|
|
1087
|
+
const now = (deps.now ?? Date.now)();
|
|
1088
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, now);
|
|
1089
|
+
if (sessionId === null) {
|
|
1090
|
+
deps.io.err('No active session pointer is available in this directory.');
|
|
1091
|
+
return EXIT.noReply;
|
|
1092
|
+
}
|
|
1093
|
+
const pending = readSessionState(sessionId, deps.env).pending ?? [];
|
|
1094
|
+
if (pending.length === 0) {
|
|
1095
|
+
if (json) {
|
|
1096
|
+
deps.io.out(JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2));
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
deps.io.err(`Session ${sessionId} has no registered question pending.`);
|
|
1100
|
+
}
|
|
1101
|
+
return EXIT.noReply;
|
|
1102
|
+
}
|
|
1103
|
+
const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env);
|
|
1104
|
+
const live = (readSessionState(sessionId, deps.env).pending ?? []).filter((entry) => entry.request_id !== undefined);
|
|
1105
|
+
const closed = [];
|
|
1106
|
+
if (live.length > 0) {
|
|
1107
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1108
|
+
const authed = authedClient(deps, config);
|
|
1109
|
+
if (!authed)
|
|
1110
|
+
return EXIT.auth;
|
|
1111
|
+
for (const entry of live) {
|
|
1112
|
+
try {
|
|
1113
|
+
await authed.client.closeReplies(entry.request_id);
|
|
1114
|
+
parkClosedQuestion(sessionId, deps.env, entry);
|
|
1115
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1116
|
+
closed.push(entry.request_id);
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const output = {
|
|
1124
|
+
session_id: sessionId,
|
|
1125
|
+
withdrawn: withdrawn.map((entry) => ({
|
|
1126
|
+
question: entry.question,
|
|
1127
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1128
|
+
})),
|
|
1129
|
+
closed,
|
|
1130
|
+
};
|
|
1131
|
+
if (json)
|
|
1132
|
+
deps.io.out(JSON.stringify(output, null, 2));
|
|
1133
|
+
else {
|
|
1134
|
+
if (withdrawn.length > 0) {
|
|
1135
|
+
deps.io.out(withdrawn.length === 1
|
|
1136
|
+
? 'Withdrew 1 unpushed question so a later Stop will not send it.'
|
|
1137
|
+
: `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`);
|
|
1138
|
+
}
|
|
1139
|
+
for (const requestId of closed) {
|
|
1140
|
+
deps.io.out(`Closed the reply window for ${requestId}.`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return EXIT.ok;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Close one outstanding question by the stable id `notifai ask` returned, or
|
|
1147
|
+
* by a request id already on a device. Unpushed entries only exist locally.
|
|
1148
|
+
* Returns null when this directory's session has no matching registration, so
|
|
1149
|
+
* the caller can still close a live request id against the service.
|
|
1150
|
+
*/
|
|
1151
|
+
async function closeLocalQuestion(deps, id, json) {
|
|
1152
|
+
const now = (deps.now ?? Date.now)();
|
|
1153
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, now);
|
|
1154
|
+
if (sessionId === null)
|
|
1155
|
+
return null;
|
|
1156
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.question_id === id || candidate.request_id === id);
|
|
1157
|
+
if (entry === undefined)
|
|
1158
|
+
return null;
|
|
1159
|
+
if (entry.request_id === undefined) {
|
|
1160
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1161
|
+
const output = {
|
|
1162
|
+
session_id: sessionId,
|
|
1163
|
+
withdrawn: [
|
|
1164
|
+
{
|
|
1165
|
+
question: entry.question,
|
|
1166
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1167
|
+
},
|
|
1168
|
+
],
|
|
1169
|
+
closed: [],
|
|
1170
|
+
};
|
|
1171
|
+
if (json)
|
|
1172
|
+
deps.io.out(JSON.stringify(output, null, 2));
|
|
1173
|
+
else {
|
|
1174
|
+
deps.io.out(`Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`);
|
|
1175
|
+
}
|
|
1176
|
+
return EXIT.ok;
|
|
1177
|
+
}
|
|
1178
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1179
|
+
const authed = authedClient(deps, config);
|
|
1180
|
+
if (!authed)
|
|
1181
|
+
return EXIT.auth;
|
|
1182
|
+
try {
|
|
1183
|
+
const response = await authed.client.closeReplies(entry.request_id);
|
|
1184
|
+
parkClosedQuestion(sessionId, deps.env, entry);
|
|
1185
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1186
|
+
if (json) {
|
|
1187
|
+
deps.io.out(JSON.stringify({
|
|
1188
|
+
...response,
|
|
1189
|
+
question_id: entry.question_id,
|
|
1190
|
+
acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
|
|
1191
|
+
}, null, 2));
|
|
1192
|
+
}
|
|
1193
|
+
else {
|
|
1194
|
+
deps.io.out(`Closed the reply window for ${entry.request_id}.`);
|
|
1195
|
+
if (response.replies.length > 0)
|
|
1196
|
+
printAcknowledgementStatus(deps, response);
|
|
1197
|
+
}
|
|
1198
|
+
return EXIT.ok;
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
function forgetClosedQuestion(deps, requestId) {
|
|
1205
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
|
|
1206
|
+
if (sessionId === null)
|
|
1207
|
+
return;
|
|
1208
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.request_id === requestId);
|
|
1209
|
+
if (entry === undefined)
|
|
1210
|
+
return;
|
|
1211
|
+
parkClosedQuestion(sessionId, deps.env, entry);
|
|
1212
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1213
|
+
}
|
|
1214
|
+
function parkClosedQuestion(sessionId, env, entry) {
|
|
1215
|
+
try {
|
|
1216
|
+
parkForRetirement(sessionId, env, entry, 'expired');
|
|
1217
|
+
}
|
|
1218
|
+
catch {
|
|
1219
|
+
// Incomplete identifiers cannot be retired on a device; dropping the
|
|
1220
|
+
// local record is still the right way to stop a later Stop from waiting.
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
861
1223
|
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
862
1224
|
export function runningViaNpx(env, scriptPath) {
|
|
863
1225
|
if (env['npm_command'] === 'exec')
|
|
@@ -891,12 +1253,19 @@ function resolveHookAdapterTarget(deps, flags) {
|
|
|
891
1253
|
}
|
|
892
1254
|
function printHooksInstallClose(deps, harness, file) {
|
|
893
1255
|
const label = HARNESS_LABELS[harness];
|
|
1256
|
+
const activation = harness === 'codex'
|
|
1257
|
+
? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
|
|
1258
|
+
: harness === 'cursor'
|
|
1259
|
+
? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
|
|
1260
|
+
: harness === 'opencode'
|
|
1261
|
+
? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
|
|
1262
|
+
: `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`;
|
|
894
1263
|
if (deps.io.interactive === true && deps.io.note) {
|
|
895
|
-
void deps.io.note(`${file}\
|
|
1264
|
+
void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`);
|
|
896
1265
|
return;
|
|
897
1266
|
}
|
|
898
1267
|
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
899
|
-
deps.io.out(
|
|
1268
|
+
deps.io.out(activation);
|
|
900
1269
|
}
|
|
901
1270
|
export function hooksInstallCommand(deps, flags) {
|
|
902
1271
|
if (flags.harness === undefined) {
|
|
@@ -931,7 +1300,18 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
931
1300
|
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
|
|
932
1301
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
|
|
933
1302
|
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
934
|
-
const
|
|
1303
|
+
const globalInstallations = otherScope.filter((installation) => installation.global);
|
|
1304
|
+
const globalInstallation = globalInstallations[0];
|
|
1305
|
+
const installedEvents = new Set(globalInstallation?.handlers
|
|
1306
|
+
.map((handler) => handlerEvent(handler.command))
|
|
1307
|
+
.filter((event) => event !== null && HOOK_EVENTS.includes(event)) ?? []);
|
|
1308
|
+
if (globalInstallations.length !== 1 ||
|
|
1309
|
+
globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
|
|
1310
|
+
globalInstallations.some((installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0) ||
|
|
1311
|
+
!requiredHookEvents(harness).every((event) => installedEvents.has(event))) {
|
|
1312
|
+
return hooksInstallCommand(deps, { ...flags, global: true, harness });
|
|
1313
|
+
}
|
|
1314
|
+
const globalFile = globalInstallation?.file;
|
|
935
1315
|
deps.io.out(`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`);
|
|
936
1316
|
return EXIT.ok;
|
|
937
1317
|
}
|
|
@@ -992,7 +1372,21 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
992
1372
|
installed =
|
|
993
1373
|
codexPaths === null
|
|
994
1374
|
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
995
|
-
: withCodexLayerTransaction(codexPaths, (inspection) =>
|
|
1375
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1376
|
+
const staleTarget = inspection.writeTarget === inspection.paths.hooksJson
|
|
1377
|
+
? inspection.paths.configToml
|
|
1378
|
+
: inspection.paths.hooksJson;
|
|
1379
|
+
const staleEvents = staleTarget === inspection.paths.hooksJson
|
|
1380
|
+
? inspection.ourJsonEvents
|
|
1381
|
+
: inspection.ourTomlEvents;
|
|
1382
|
+
if (staleEvents.length > 0) {
|
|
1383
|
+
const staleDocument = loadSettings(staleTarget);
|
|
1384
|
+
const stripped = removeHooks(staleDocument, scriptPath);
|
|
1385
|
+
if (stripped.replaced.length > 0)
|
|
1386
|
+
applyPlan(staleTarget, stripped.document);
|
|
1387
|
+
}
|
|
1388
|
+
return installInto(inspection.writeTarget);
|
|
1389
|
+
});
|
|
996
1390
|
}
|
|
997
1391
|
catch (err) {
|
|
998
1392
|
deps.io.err(String(err));
|
|
@@ -1028,7 +1422,7 @@ function foreignStopHandlers(document) {
|
|
|
1028
1422
|
return [];
|
|
1029
1423
|
return groups
|
|
1030
1424
|
.flatMap((group) => group.hooks ?? [])
|
|
1031
|
-
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
1425
|
+
.filter((handler) => !/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
1032
1426
|
}
|
|
1033
1427
|
/**
|
|
1034
1428
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
@@ -1146,7 +1540,11 @@ export function hooksUninstallCommand(deps, flags) {
|
|
|
1146
1540
|
result =
|
|
1147
1541
|
codexPaths === null
|
|
1148
1542
|
? removeInstalledHooks()
|
|
1149
|
-
: withCodexLayerTransaction(codexPaths,
|
|
1543
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1544
|
+
const stripped = removeInstalledHooks();
|
|
1545
|
+
cleanupEmptiedCodexLayer(inspection.paths);
|
|
1546
|
+
return stripped;
|
|
1547
|
+
});
|
|
1150
1548
|
}
|
|
1151
1549
|
catch (err) {
|
|
1152
1550
|
deps.io.err(String(err));
|