@raidiant/notifai 7.0.2 → 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 +75 -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-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- 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 +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- 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 +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- 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 +238 -92
- 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 +14 -9
- 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 +5 -487
- 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.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- 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/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- 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 +6 -4
- 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-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- 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 +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- 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
|
*
|
|
@@ -374,13 +569,12 @@ export function buildQuestions(flags, question) {
|
|
|
374
569
|
questions.push(built.question);
|
|
375
570
|
}
|
|
376
571
|
const context = flags.body ?? record['body'];
|
|
377
|
-
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
|
|
378
572
|
return {
|
|
379
573
|
ok: true,
|
|
380
574
|
questions,
|
|
381
575
|
body: context !== undefined && context.trim() !== ''
|
|
382
|
-
?
|
|
383
|
-
:
|
|
576
|
+
? context
|
|
577
|
+
: questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n'),
|
|
384
578
|
};
|
|
385
579
|
}
|
|
386
580
|
if (question === undefined || question.trim() === '') {
|
|
@@ -393,9 +587,7 @@ export function buildQuestions(flags, question) {
|
|
|
393
587
|
return {
|
|
394
588
|
ok: true,
|
|
395
589
|
questions: [built.question],
|
|
396
|
-
body: context !== undefined && context.trim() !== ''
|
|
397
|
-
? `${built.question.text}\n\n${context}`
|
|
398
|
-
: built.question.text,
|
|
590
|
+
body: context !== undefined && context.trim() !== '' ? context : built.question.text,
|
|
399
591
|
};
|
|
400
592
|
}
|
|
401
593
|
function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
@@ -452,8 +644,9 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
|
|
|
452
644
|
return { ok: true, draft: result.draft };
|
|
453
645
|
}
|
|
454
646
|
function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
647
|
+
let questionId;
|
|
455
648
|
try {
|
|
456
|
-
registerQuestion(sessionId, deps.env, {
|
|
649
|
+
questionId = registerQuestion(sessionId, deps.env, {
|
|
457
650
|
question: built.questions[0].text,
|
|
458
651
|
questions: built.questions,
|
|
459
652
|
body: draft.presentation.body,
|
|
@@ -470,8 +663,9 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
470
663
|
log(deps).info('ask.registered', {
|
|
471
664
|
ok: true,
|
|
472
665
|
session: sessionId,
|
|
666
|
+
question_id: questionId,
|
|
473
667
|
questions: built.questions.length,
|
|
474
|
-
|
|
668
|
+
text_chars: built.questions[0].text.length,
|
|
475
669
|
choices: built.questions[0].choices?.length ?? 0,
|
|
476
670
|
media: draft.presentation.media?.length ?? 0,
|
|
477
671
|
});
|
|
@@ -482,17 +676,20 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
482
676
|
if (json) {
|
|
483
677
|
deps.io.out(JSON.stringify({
|
|
484
678
|
registered: true,
|
|
679
|
+
question_id: questionId,
|
|
485
680
|
questions: built.questions.map((entry) => ({
|
|
486
681
|
id: entry.id,
|
|
487
682
|
text: entry.text,
|
|
488
683
|
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
489
684
|
...(entry.multi === true ? { multi: true } : {}),
|
|
490
685
|
})),
|
|
686
|
+
close: `notifai close ${questionId}`,
|
|
491
687
|
next: {
|
|
492
688
|
end_turn: true,
|
|
493
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.',
|
|
494
690
|
route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
495
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.`,
|
|
496
693
|
},
|
|
497
694
|
}, null, 2));
|
|
498
695
|
return EXIT.ok;
|
|
@@ -508,8 +705,8 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
508
705
|
}
|
|
509
706
|
}
|
|
510
707
|
deps.io.out(built.questions.length > 1
|
|
511
|
-
? `${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.`
|
|
512
|
-
:
|
|
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.`);
|
|
513
710
|
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
514
711
|
for (const [index, entry] of built.questions.entries()) {
|
|
515
712
|
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
@@ -525,6 +722,7 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
525
722
|
}
|
|
526
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.');
|
|
527
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.`);
|
|
528
726
|
return EXIT.ok;
|
|
529
727
|
}
|
|
530
728
|
async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
|
|
@@ -537,7 +735,7 @@ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation)
|
|
|
537
735
|
mediaIds.push(image);
|
|
538
736
|
continue;
|
|
539
737
|
}
|
|
540
|
-
const uploaded = await uploadImage(deps, authed.client, image);
|
|
738
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value);
|
|
541
739
|
if (!uploaded.ok) {
|
|
542
740
|
if (uploaded.error !== null)
|
|
543
741
|
deps.io.err(uploaded.error);
|
|
@@ -561,6 +759,11 @@ export function askCommand(deps, question, flags) {
|
|
|
561
759
|
// Validate before route discovery. A malformed question belongs to the
|
|
562
760
|
// caller and should not be hidden behind whichever harness setup issue
|
|
563
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
|
+
}
|
|
564
767
|
const built = buildQuestions(flags, question);
|
|
565
768
|
if (!built.ok) {
|
|
566
769
|
deps.io.err(built.error);
|
|
@@ -759,19 +962,19 @@ export function hookActivationAdvice(installations) {
|
|
|
759
962
|
const harnesses = new Set(installations.map((installation) => installation.harness));
|
|
760
963
|
const advice = [];
|
|
761
964
|
if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
|
|
762
|
-
advice.push('Claude Code:
|
|
965
|
+
advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`');
|
|
763
966
|
}
|
|
764
967
|
if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
|
|
765
|
-
advice.push('Claude Code global hooks:
|
|
968
|
+
advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`');
|
|
766
969
|
}
|
|
767
970
|
if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
|
|
768
|
-
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`');
|
|
769
972
|
}
|
|
770
973
|
if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
|
|
771
|
-
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`');
|
|
772
975
|
}
|
|
773
976
|
if (harnesses.has('codex')) {
|
|
774
|
-
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`');
|
|
775
978
|
}
|
|
776
979
|
if (harnesses.has('opencode')) {
|
|
777
980
|
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
|
|
@@ -800,7 +1003,6 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
800
1003
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
801
1004
|
logger.info('acknowledgement.attempted', {
|
|
802
1005
|
request_id: requestId,
|
|
803
|
-
text,
|
|
804
1006
|
characters: text.length,
|
|
805
1007
|
});
|
|
806
1008
|
const authed = authedClient(deps, config);
|
|
@@ -817,7 +1019,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
817
1019
|
logger.info('acknowledgement.outcome', {
|
|
818
1020
|
request_id: requestId,
|
|
819
1021
|
outcome: result.status,
|
|
820
|
-
|
|
1022
|
+
text_chars: result.agent_acknowledgement.text.length,
|
|
821
1023
|
created_at: result.agent_acknowledgement.created_at,
|
|
822
1024
|
agent_acknowledgement_required: true,
|
|
823
1025
|
});
|
|
@@ -838,12 +1040,27 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
838
1040
|
}
|
|
839
1041
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
840
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;
|
|
841
1057
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
842
1058
|
const authed = authedClient(deps, config);
|
|
843
1059
|
if (!authed)
|
|
844
1060
|
return EXIT.auth;
|
|
845
1061
|
try {
|
|
846
1062
|
const response = await authed.client.closeReplies(requestId);
|
|
1063
|
+
forgetClosedQuestion(deps, requestId);
|
|
847
1064
|
if (flags.json) {
|
|
848
1065
|
deps.io.out(JSON.stringify({
|
|
849
1066
|
...response,
|
|
@@ -861,6 +1078,148 @@ export async function closeCommand(deps, requestId, flags = {}) {
|
|
|
861
1078
|
return reportError(deps, err);
|
|
862
1079
|
}
|
|
863
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
|
+
}
|
|
864
1223
|
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
865
1224
|
export function runningViaNpx(env, scriptPath) {
|
|
866
1225
|
if (env['npm_command'] === 'exec')
|
|
@@ -894,12 +1253,19 @@ function resolveHookAdapterTarget(deps, flags) {
|
|
|
894
1253
|
}
|
|
895
1254
|
function printHooksInstallClose(deps, harness, file) {
|
|
896
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\`.`;
|
|
897
1263
|
if (deps.io.interactive === true && deps.io.note) {
|
|
898
|
-
void deps.io.note(`${file}\
|
|
1264
|
+
void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`);
|
|
899
1265
|
return;
|
|
900
1266
|
}
|
|
901
1267
|
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
902
|
-
deps.io.out(
|
|
1268
|
+
deps.io.out(activation);
|
|
903
1269
|
}
|
|
904
1270
|
export function hooksInstallCommand(deps, flags) {
|
|
905
1271
|
if (flags.harness === undefined) {
|
|
@@ -934,7 +1300,18 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
934
1300
|
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
|
|
935
1301
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
|
|
936
1302
|
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
937
|
-
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;
|
|
938
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}`);
|
|
939
1316
|
return EXIT.ok;
|
|
940
1317
|
}
|
|
@@ -995,7 +1372,21 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
995
1372
|
installed =
|
|
996
1373
|
codexPaths === null
|
|
997
1374
|
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
998
|
-
: 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
|
+
});
|
|
999
1390
|
}
|
|
1000
1391
|
catch (err) {
|
|
1001
1392
|
deps.io.err(String(err));
|
|
@@ -1031,7 +1422,7 @@ function foreignStopHandlers(document) {
|
|
|
1031
1422
|
return [];
|
|
1032
1423
|
return groups
|
|
1033
1424
|
.flatMap((group) => group.hooks ?? [])
|
|
1034
|
-
.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));
|
|
1035
1426
|
}
|
|
1036
1427
|
/**
|
|
1037
1428
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
@@ -1149,7 +1540,11 @@ export function hooksUninstallCommand(deps, flags) {
|
|
|
1149
1540
|
result =
|
|
1150
1541
|
codexPaths === null
|
|
1151
1542
|
? removeInstalledHooks()
|
|
1152
|
-
: withCodexLayerTransaction(codexPaths,
|
|
1543
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1544
|
+
const stripped = removeInstalledHooks();
|
|
1545
|
+
cleanupEmptiedCodexLayer(inspection.paths);
|
|
1546
|
+
return stripped;
|
|
1547
|
+
});
|
|
1153
1548
|
}
|
|
1154
1549
|
catch (err) {
|
|
1155
1550
|
deps.io.err(String(err));
|