@raidiant/notifai 8.0.0 → 9.1.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 +48 -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/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- 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 +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- 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.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- 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 -2
- package/dist/commands.js +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-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- 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 +83 -7
- package/dist/hooks.js +288 -90
- 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 +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- 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 +11 -0
- package/dist/session-activation.js +46 -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/command-session.ts +38 -0
- 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 +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -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-render.ts +56 -0
- package/src/guidance.test.ts +70 -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 +778 -166
- package/src/hooks.ts +368 -129
- 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 +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/dist/commands-hooks.js
CHANGED
|
@@ -9,28 +9,46 @@ 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,
|
|
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, 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';
|
|
24
|
+
import { resolveCommandSession } from './command-session.js';
|
|
22
25
|
// ---------------------------------------------------------------------------
|
|
23
26
|
// hook / ask / close — harness integration
|
|
24
27
|
// ---------------------------------------------------------------------------
|
|
25
|
-
export const HOOK_EVENTS = [
|
|
28
|
+
export const HOOK_EVENTS = [
|
|
29
|
+
'session-start',
|
|
30
|
+
'subagent-start',
|
|
31
|
+
'activation-stop',
|
|
32
|
+
'user-prompt-submit',
|
|
33
|
+
'stop',
|
|
34
|
+
'session-end',
|
|
35
|
+
];
|
|
36
|
+
/** Lifecycle handlers one installed harness must carry in this CLI build. */
|
|
37
|
+
export function requiredHookEvents(harness) {
|
|
38
|
+
if (harness === 'opencode')
|
|
39
|
+
return [];
|
|
40
|
+
return harness === 'cursor'
|
|
41
|
+
? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
|
|
42
|
+
: ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end'];
|
|
43
|
+
}
|
|
26
44
|
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
27
45
|
export function hookDefersDiagnosticsUntilAfterCleanup(event) {
|
|
28
46
|
return event === 'session-end';
|
|
29
47
|
}
|
|
30
48
|
/**
|
|
31
49
|
* 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".
|
|
50
|
+
* stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
|
|
51
|
+
* exit 0 with no stdout means "no decision or added context — carry on as normal".
|
|
34
52
|
*
|
|
35
53
|
* Every failure path in here must reach that no-decision state. A hook that
|
|
36
54
|
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
@@ -63,6 +81,20 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
63
81
|
const failureData = (err) => err instanceof ApiCallError
|
|
64
82
|
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
65
83
|
: { message: err instanceof Error ? err.message : String(err) };
|
|
84
|
+
// Cursor may load ~/.claude/settings.json in addition to its own native
|
|
85
|
+
// hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
|
|
86
|
+
// compatibility copy becomes a no-op and the native Cursor definition is
|
|
87
|
+
// the single owner. Real Claude hooks do not receive that hook-only marker.
|
|
88
|
+
if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
|
|
89
|
+
start({ outcome: 'cursor-compatibility-copy-skipped' });
|
|
90
|
+
logger.info('hook.end', {
|
|
91
|
+
hook: event,
|
|
92
|
+
outcome: 'ignored',
|
|
93
|
+
reason: 'cursor-native-handler-owns-event',
|
|
94
|
+
decided: false,
|
|
95
|
+
});
|
|
96
|
+
return EXIT.ok;
|
|
97
|
+
}
|
|
66
98
|
let raw;
|
|
67
99
|
try {
|
|
68
100
|
raw = await readStdin();
|
|
@@ -105,6 +137,164 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
105
137
|
const cwd = envelope.cwd ?? deps.cwd;
|
|
106
138
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
|
|
107
139
|
logger.bind({ session: envelope.session_id ?? null });
|
|
140
|
+
const declaredSourcePid = declaredHookSourcePid(deps);
|
|
141
|
+
const activationOwnerPid = harness === 'claude-code'
|
|
142
|
+
? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
|
|
143
|
+
: harness === 'codex'
|
|
144
|
+
? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
|
|
145
|
+
: undefined;
|
|
146
|
+
// Session activation is local model context, not authenticated routing. It
|
|
147
|
+
// must survive the exact first-run states it exists to repair: no config,
|
|
148
|
+
// no credential, no Companion device, and no network.
|
|
149
|
+
if (event === 'session-start' || event === 'subagent-start') {
|
|
150
|
+
start({ cwd, source: envelope.source ?? null });
|
|
151
|
+
if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
|
|
152
|
+
try {
|
|
153
|
+
resetCursorStopActivation(envelope.session_id, deps.env);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
logger.error('hook.end', {
|
|
157
|
+
hook: event,
|
|
158
|
+
outcome: 'reset-failed',
|
|
159
|
+
...failureData(err),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const stdout = sessionActivationOutput(harness, event === 'session-start' ? 'SessionStart' : 'SubagentStart', cwd, deps.env);
|
|
164
|
+
if (stdout !== undefined)
|
|
165
|
+
deps.io.out(stdout);
|
|
166
|
+
if (stdout !== undefined &&
|
|
167
|
+
event === 'session-start' &&
|
|
168
|
+
activationOwnerPid !== undefined &&
|
|
169
|
+
envelope.session_id !== undefined) {
|
|
170
|
+
try {
|
|
171
|
+
// Write stdout first. If the process dies between these operations a
|
|
172
|
+
// later prompt may duplicate context, but it cannot suppress context
|
|
173
|
+
// that the host never received.
|
|
174
|
+
markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
logger.error('hook.end', {
|
|
178
|
+
hook: event,
|
|
179
|
+
outcome: 'mark-failed',
|
|
180
|
+
reason: 'activation-state-failed',
|
|
181
|
+
...failureData(err),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
logger.info('hook.end', {
|
|
186
|
+
hook: event,
|
|
187
|
+
outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
|
|
188
|
+
decided: false,
|
|
189
|
+
});
|
|
190
|
+
return EXIT.ok;
|
|
191
|
+
}
|
|
192
|
+
// SessionStart is the preferred model-visible activation seam. A few
|
|
193
|
+
// managed hosts regenerate older hook definitions that retain
|
|
194
|
+
// UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
|
|
195
|
+
// once per exact session as a compatibility fallback, before config/auth/
|
|
196
|
+
// device/network work, and stay silent when SessionStart already ran.
|
|
197
|
+
let promptActivationStdout;
|
|
198
|
+
if (event === 'user-prompt-submit' &&
|
|
199
|
+
(harness === 'claude-code' || harness === 'codex') &&
|
|
200
|
+
activationOwnerPid !== undefined &&
|
|
201
|
+
envelope.session_id !== undefined) {
|
|
202
|
+
try {
|
|
203
|
+
if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)) {
|
|
204
|
+
promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit', cwd, deps.env);
|
|
205
|
+
if (promptActivationStdout !== undefined)
|
|
206
|
+
deps.io.out(promptActivationStdout);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
logger.error('hook.end', {
|
|
211
|
+
hook: event,
|
|
212
|
+
outcome: 'claim-failed',
|
|
213
|
+
reason: 'activation-state-failed',
|
|
214
|
+
...failureData(err),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Cursor has a confirmed host bug in which sessionStart.additional_context
|
|
219
|
+
// is accepted but never reaches the model. A native Stop follow-up is the
|
|
220
|
+
// host's guaranteed model-visible channel. Claim it once per conversation,
|
|
221
|
+
// before config/auth/network, and leave the ordinary Stop handler separate
|
|
222
|
+
// so question delivery is never displaced by activation.
|
|
223
|
+
if (event === 'activation-stop') {
|
|
224
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
225
|
+
if (harness !== 'cursor') {
|
|
226
|
+
logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false });
|
|
227
|
+
return EXIT.ok;
|
|
228
|
+
}
|
|
229
|
+
if (envelope.status === 'aborted') {
|
|
230
|
+
logger.info('hook.end', {
|
|
231
|
+
hook: event,
|
|
232
|
+
outcome: 'ignored',
|
|
233
|
+
reason: `turn-${envelope.status}`,
|
|
234
|
+
decided: false,
|
|
235
|
+
});
|
|
236
|
+
return EXIT.ok;
|
|
237
|
+
}
|
|
238
|
+
if (envelope.session_id === undefined) {
|
|
239
|
+
logger.info('hook.end', {
|
|
240
|
+
hook: event,
|
|
241
|
+
outcome: 'ignored',
|
|
242
|
+
reason: 'missing-conversation-id',
|
|
243
|
+
decided: false,
|
|
244
|
+
});
|
|
245
|
+
return EXIT.ok;
|
|
246
|
+
}
|
|
247
|
+
if (envelope.session_id !== undefined &&
|
|
248
|
+
typeof envelope.loop_count === 'number' &&
|
|
249
|
+
envelope.loop_count > 0) {
|
|
250
|
+
let activationOwned = false;
|
|
251
|
+
try {
|
|
252
|
+
activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now());
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
logger.error('hook.end', {
|
|
256
|
+
hook: event,
|
|
257
|
+
outcome: 'confirm-failed',
|
|
258
|
+
...failureData(err),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (activationOwned) {
|
|
262
|
+
logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false });
|
|
263
|
+
return EXIT.ok;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const cursorState = readSessionState(envelope.session_id, deps.env);
|
|
267
|
+
if ((cursorState.pending?.length ?? 0) > 0 ||
|
|
268
|
+
cursorState.accepted !== undefined ||
|
|
269
|
+
(cursorState.acknowledgement_due?.length ?? 0) > 0) {
|
|
270
|
+
logger.info('hook.end', {
|
|
271
|
+
hook: event,
|
|
272
|
+
outcome: 'deferred',
|
|
273
|
+
reason: 'question-continuation-owns-stop',
|
|
274
|
+
decided: false,
|
|
275
|
+
});
|
|
276
|
+
return EXIT.ok;
|
|
277
|
+
}
|
|
278
|
+
let claimed = false;
|
|
279
|
+
try {
|
|
280
|
+
claimed = claimCursorStopActivation(envelope.session_id, deps.env, now());
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
logger.error('hook.end', {
|
|
284
|
+
hook: event,
|
|
285
|
+
outcome: 'claim-failed',
|
|
286
|
+
...failureData(err),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
logger.info('hook.end', {
|
|
290
|
+
hook: event,
|
|
291
|
+
outcome: claimed ? 'followup-added' : 'already-activated',
|
|
292
|
+
decided: claimed,
|
|
293
|
+
});
|
|
294
|
+
if (claimed)
|
|
295
|
+
deps.io.out(cursorStopActivationOutput(cwd, deps.env));
|
|
296
|
+
return EXIT.ok;
|
|
297
|
+
}
|
|
108
298
|
let config = null;
|
|
109
299
|
let configFailure;
|
|
110
300
|
try {
|
|
@@ -127,6 +317,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
127
317
|
hook: event,
|
|
128
318
|
outcome: 'failed',
|
|
129
319
|
reason: 'config-failed',
|
|
320
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
130
321
|
...failureData(err),
|
|
131
322
|
});
|
|
132
323
|
for (const line of describeHookFailure(err))
|
|
@@ -174,9 +365,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
174
365
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
175
366
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
176
367
|
const baseUrl = credential.baseUrl;
|
|
177
|
-
|
|
178
|
-
deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
|
|
179
|
-
}
|
|
368
|
+
diagnoseIgnoredOriginOverride(deps.io, resolved, credential);
|
|
180
369
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
181
370
|
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
182
371
|
// Stop is allowed to block and keeps the ordinary budget.
|
|
@@ -222,9 +411,10 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
222
411
|
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
|
|
223
412
|
logger.info('hook.end', {
|
|
224
413
|
hook: event,
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
decided: outcome.stdout !== undefined,
|
|
414
|
+
// Stop stdout takes over the turn. UserPromptSubmit stdout may instead
|
|
415
|
+
// add context to the user's own turn, which is explicitly non-decisive.
|
|
416
|
+
decided: outcome.decided ?? outcome.stdout !== undefined,
|
|
417
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
228
418
|
...(notes.length === 0 ? {} : { notes }),
|
|
229
419
|
...outcome.log,
|
|
230
420
|
});
|
|
@@ -264,13 +454,14 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
264
454
|
function stopWakeRoute(deps, harness, sessionId, cwd) {
|
|
265
455
|
if (sessionId === undefined)
|
|
266
456
|
return undefined;
|
|
457
|
+
const declaredSourcePid = declaredHookSourcePid(deps);
|
|
267
458
|
if (harness === 'claude-code') {
|
|
268
459
|
if ((deps.hookPlatform ?? process.platform) === 'win32')
|
|
269
460
|
return undefined;
|
|
270
461
|
return claudeWakeRoute({
|
|
271
462
|
sessionId,
|
|
272
463
|
cwd,
|
|
273
|
-
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
464
|
+
sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
|
|
274
465
|
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
275
466
|
});
|
|
276
467
|
}
|
|
@@ -278,13 +469,18 @@ function stopWakeRoute(deps, harness, sessionId, cwd) {
|
|
|
278
469
|
return codexWakeRoute({
|
|
279
470
|
threadId: sessionId,
|
|
280
471
|
cwd,
|
|
281
|
-
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
472
|
+
sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
|
|
282
473
|
env: deps.env,
|
|
283
474
|
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
284
475
|
});
|
|
285
476
|
}
|
|
286
477
|
return undefined;
|
|
287
478
|
}
|
|
479
|
+
/** Stable harness parent propagated by the managed adapter across child tools. */
|
|
480
|
+
function declaredHookSourcePid(deps) {
|
|
481
|
+
const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID']);
|
|
482
|
+
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
483
|
+
}
|
|
288
484
|
/**
|
|
289
485
|
* What went wrong, in terms of what to do about it.
|
|
290
486
|
*
|
|
@@ -315,6 +511,26 @@ export function describeHookFailure(err) {
|
|
|
315
511
|
}
|
|
316
512
|
return lines;
|
|
317
513
|
}
|
|
514
|
+
/** One stable failure contract for every machine-readable ask refusal. */
|
|
515
|
+
export function reportAskFailure(deps, flags, failure) {
|
|
516
|
+
if (flags.json === true) {
|
|
517
|
+
deps.io.out(JSON.stringify({ ok: false, registered: false, ...failure }, null, 2));
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
deps.io.err(failure.message);
|
|
521
|
+
deps.io.err(`next: ${failure.remedy}`);
|
|
522
|
+
}
|
|
523
|
+
return failure.exit_code;
|
|
524
|
+
}
|
|
525
|
+
function askFailure(deps, flags, code, checkId, message, remedy, exitCode = EXIT.usage) {
|
|
526
|
+
return reportAskFailure(deps, flags, {
|
|
527
|
+
code,
|
|
528
|
+
check_id: checkId,
|
|
529
|
+
exit_code: exitCode,
|
|
530
|
+
remedy,
|
|
531
|
+
message,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
318
534
|
/**
|
|
319
535
|
* Turn ask input into questions plus their canonical body. Everything is
|
|
320
536
|
* validated at registration because a later hook failure is easy to miss.
|
|
@@ -449,8 +665,9 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
|
|
|
449
665
|
return { ok: true, draft: result.draft };
|
|
450
666
|
}
|
|
451
667
|
function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
668
|
+
let questionId;
|
|
452
669
|
try {
|
|
453
|
-
registerQuestion(sessionId, deps.env, {
|
|
670
|
+
questionId = registerQuestion(sessionId, deps.env, {
|
|
454
671
|
question: built.questions[0].text,
|
|
455
672
|
questions: built.questions,
|
|
456
673
|
body: draft.presentation.body,
|
|
@@ -461,14 +678,14 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
461
678
|
}
|
|
462
679
|
catch (err) {
|
|
463
680
|
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
|
|
464
|
-
deps
|
|
465
|
-
return EXIT.failed;
|
|
681
|
+
return askFailure(deps, { json }, 'registration_failed', 'question_registration', `Could not register the question: ${err instanceof Error ? err.message : String(err)}`, 'retire an older pending question if the limit was reached, then retry the same ask', EXIT.failed);
|
|
466
682
|
}
|
|
467
683
|
log(deps).info('ask.registered', {
|
|
468
684
|
ok: true,
|
|
469
685
|
session: sessionId,
|
|
686
|
+
question_id: questionId,
|
|
470
687
|
questions: built.questions.length,
|
|
471
|
-
|
|
688
|
+
text_chars: built.questions[0].text.length,
|
|
472
689
|
choices: built.questions[0].choices?.length ?? 0,
|
|
473
690
|
media: draft.presentation.media?.length ?? 0,
|
|
474
691
|
});
|
|
@@ -479,17 +696,20 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
479
696
|
if (json) {
|
|
480
697
|
deps.io.out(JSON.stringify({
|
|
481
698
|
registered: true,
|
|
699
|
+
question_id: questionId,
|
|
482
700
|
questions: built.questions.map((entry) => ({
|
|
483
701
|
id: entry.id,
|
|
484
702
|
text: entry.text,
|
|
485
703
|
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
486
704
|
...(entry.multi === true ? { multi: true } : {}),
|
|
487
705
|
})),
|
|
706
|
+
close: `notifai close ${questionId}`,
|
|
488
707
|
next: {
|
|
489
708
|
end_turn: true,
|
|
490
709
|
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
710
|
route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
492
711
|
on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
712
|
+
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
713
|
},
|
|
494
714
|
}, null, 2));
|
|
495
715
|
return EXIT.ok;
|
|
@@ -505,8 +725,8 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
505
725
|
}
|
|
506
726
|
}
|
|
507
727
|
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
|
-
:
|
|
728
|
+
? `${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.`
|
|
729
|
+
: `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`);
|
|
510
730
|
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
511
731
|
for (const [index, entry] of built.questions.entries()) {
|
|
512
732
|
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
@@ -522,30 +742,31 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
|
522
742
|
}
|
|
523
743
|
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
744
|
deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
|
|
745
|
+
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
746
|
return EXIT.ok;
|
|
526
747
|
}
|
|
527
748
|
async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
|
|
528
749
|
const authed = authedClient(deps, config);
|
|
529
|
-
if (!authed)
|
|
530
|
-
return EXIT.auth;
|
|
750
|
+
if (!authed) {
|
|
751
|
+
return askFailure(deps, flags, 'auth_required', 'credential', 'Question routing is not paired on this machine.', 'run `notifai init --json`', EXIT.auth);
|
|
752
|
+
}
|
|
531
753
|
const mediaIds = [];
|
|
532
754
|
for (const image of flags.image ?? []) {
|
|
533
755
|
if (image.startsWith('med_')) {
|
|
534
756
|
mediaIds.push(image);
|
|
535
757
|
continue;
|
|
536
758
|
}
|
|
537
|
-
const uploaded = await uploadImage(deps, authed.client, image);
|
|
759
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value);
|
|
538
760
|
if (!uploaded.ok) {
|
|
539
761
|
if (uploaded.error !== null)
|
|
540
762
|
deps.io.err(uploaded.error);
|
|
541
|
-
return uploaded.exit;
|
|
763
|
+
return askFailure(deps, flags, 'media_upload_failed', 'media', uploaded.error ?? 'The image upload failed.', 'fix the reported image or network problem, then retry the same ask', uploaded.exit);
|
|
542
764
|
}
|
|
543
765
|
mediaIds.push(uploaded.mediaId);
|
|
544
766
|
}
|
|
545
767
|
const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
|
|
546
768
|
if (!ready.ok) {
|
|
547
|
-
deps
|
|
548
|
-
return EXIT.usage;
|
|
769
|
+
return askFailure(deps, flags, 'invalid_draft', 'draft', ready.error, 'fix the reported field and retry');
|
|
549
770
|
}
|
|
550
771
|
return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true);
|
|
551
772
|
}
|
|
@@ -558,24 +779,20 @@ export function askCommand(deps, question, flags) {
|
|
|
558
779
|
// Validate before route discovery. A malformed question belongs to the
|
|
559
780
|
// caller and should not be hidden behind whichever harness setup issue
|
|
560
781
|
// happens to exist on this machine.
|
|
782
|
+
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN);
|
|
783
|
+
if (escapedBody !== null) {
|
|
784
|
+
return askFailure(deps, flags, 'invalid_input', 'body', escapedBody, 'fix the body and retry');
|
|
785
|
+
}
|
|
561
786
|
const built = buildQuestions(flags, question);
|
|
562
787
|
if (!built.ok) {
|
|
563
|
-
deps
|
|
564
|
-
return EXIT.usage;
|
|
788
|
+
return askFailure(deps, flags, 'invalid_input', 'questions', built.error, 'fix the question input and retry');
|
|
565
789
|
}
|
|
566
790
|
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
|
|
567
791
|
if (mediaInputError !== null) {
|
|
568
|
-
deps
|
|
569
|
-
return EXIT.usage;
|
|
570
|
-
}
|
|
571
|
-
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
572
|
-
if (!routingConfig.ask_notifications.value) {
|
|
573
|
-
deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
|
|
574
|
-
return EXIT.usage;
|
|
792
|
+
return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry');
|
|
575
793
|
}
|
|
576
794
|
if (deps.store.load() === null) {
|
|
577
|
-
deps
|
|
578
|
-
return EXIT.usage;
|
|
795
|
+
return askFailure(deps, flags, 'auth_required', 'credential', 'Question routing is not paired on this machine.', 'run `notifai init --json`', EXIT.auth);
|
|
579
796
|
}
|
|
580
797
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
581
798
|
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
@@ -584,66 +801,64 @@ export function askCommand(deps, question, flags) {
|
|
|
584
801
|
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now);
|
|
585
802
|
let sessionId;
|
|
586
803
|
if (active !== null) {
|
|
587
|
-
|
|
804
|
+
if (contested.length > 1) {
|
|
805
|
+
return askFailure(deps, flags, 'session_identity_ambiguous', 'exact_session', `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}).`, 'run the ask from a shell with one exact active harness session');
|
|
806
|
+
}
|
|
807
|
+
const exactState = active.sessionId === undefined
|
|
808
|
+
? null
|
|
809
|
+
: readSessionState(active.sessionId, deps.env);
|
|
810
|
+
const installationCwd = exactState?.activation_cwd ?? deps.cwd;
|
|
811
|
+
const installationDeps = installationCwd === deps.cwd ? deps : { ...deps, cwd: installationCwd };
|
|
812
|
+
const installations = findInstallations(installationCwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
|
|
588
813
|
const activeInstalled = installations.some((installation) => installation.harness === active.harness);
|
|
589
814
|
if (!activeInstalled) {
|
|
590
|
-
|
|
591
|
-
deps.io.err(line);
|
|
592
|
-
}
|
|
593
|
-
return EXIT.usage;
|
|
815
|
+
return askFailure(deps, flags, 'hooks_not_installed', 'hook_installation', `Notifai ${active.label} hooks are not installed for this project.`, `run \`notifai init --json\` from this ${active.label} session`);
|
|
594
816
|
}
|
|
595
817
|
if (active.sessionId === undefined) {
|
|
596
|
-
|
|
597
|
-
deps.io.err(`Question routing is not ready: ${problem}`);
|
|
598
|
-
}
|
|
599
|
-
return EXIT.usage;
|
|
818
|
+
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', `The active ${active.label} shell does not expose an exact session id.`, 'use a blocking `notifai send --reply` question');
|
|
600
819
|
}
|
|
601
|
-
const
|
|
602
|
-
if (projectPointer === null) {
|
|
603
|
-
for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations, contested)) {
|
|
604
|
-
deps.io.err(line);
|
|
605
|
-
}
|
|
606
|
-
return EXIT.usage;
|
|
607
|
-
}
|
|
608
|
-
const routeProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
820
|
+
const routeProblems = activeQuestionRouteProblems(installationDeps, active, installations);
|
|
609
821
|
if (routeProblems.length > 0) {
|
|
610
|
-
|
|
611
|
-
deps.io.err(`Question routing is not ready: ${problem}`);
|
|
612
|
-
return EXIT.usage;
|
|
822
|
+
return askFailure(deps, flags, 'question_routing_unavailable', 'hook_contract', `Question routing is not ready: ${routeProblems.join('; ')}`, 'run `notifai init --json` and follow its question-routing remedy');
|
|
613
823
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
return
|
|
824
|
+
const state = exactState ?? readSessionState(active.sessionId, deps.env);
|
|
825
|
+
if (state.harness !== active.harness || state.last_prompt_at === undefined) {
|
|
826
|
+
return askFailure(deps, flags, 'session_not_activated', 'user_prompt_submit', `This exact ${active.label} session has not fired UserPromptSubmit.`, `send one prompt in this ${active.label} session, then retry \`notifai ask --json\``);
|
|
617
827
|
}
|
|
618
|
-
|
|
828
|
+
if (state.last_stop_at === undefined) {
|
|
829
|
+
return askFailure(deps, flags, 'stop_not_observed', 'stop_hook', `This exact ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed.`, `end one harmless turn, send a new prompt, then retry \`notifai ask --json\``);
|
|
830
|
+
}
|
|
831
|
+
sessionId = active.sessionId;
|
|
619
832
|
}
|
|
620
833
|
else {
|
|
621
|
-
|
|
622
|
-
deps.io.err(line);
|
|
623
|
-
deps.io.err('Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.');
|
|
624
|
-
return EXIT.usage;
|
|
834
|
+
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'Could not prove which live harness session owns this command.', 'run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question');
|
|
625
835
|
}
|
|
626
836
|
if (!sessionId) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
837
|
+
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active session is available.', 'use a blocking `notifai send --reply` question');
|
|
838
|
+
}
|
|
839
|
+
let routingConfig;
|
|
840
|
+
try {
|
|
841
|
+
routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env, sessionId });
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
return askFailure(deps, flags, 'config_invalid', 'configuration', `Question routing configuration is invalid: ${String(err)}`, 'fix the reported Notifai configuration and retry');
|
|
845
|
+
}
|
|
846
|
+
if (!routingConfig.ask_notifications.value) {
|
|
847
|
+
return askFailure(deps, flags, 'question_routing_disabled', 'ask_notifications', 'Question routing is disabled by ask_notifications=false.', 'enable question routing or use a blocking `notifai send --reply` question');
|
|
630
848
|
}
|
|
631
849
|
const source = resolveDraftInvocation(deps, flags, active);
|
|
632
850
|
if (!source.ok) {
|
|
633
|
-
deps
|
|
634
|
-
return EXIT.usage;
|
|
851
|
+
return askFailure(deps, flags, 'invalid_source', 'source_context', source.error, 'remove the conflicting source override and retry');
|
|
635
852
|
}
|
|
636
853
|
if (source.invocation.source?.session_id !== sessionId) {
|
|
637
|
-
deps
|
|
638
|
-
return EXIT.usage;
|
|
854
|
+
return askFailure(deps, flags, 'session_identity_mismatch', 'exact_session', `The inferred session does not match the exact active ${active.label} session.`, 'remove NOTIFAI_SESSION_ID and retry from the exact active session');
|
|
639
855
|
}
|
|
640
856
|
// Placeholders let every body, source, project, media, and payload limit fail
|
|
641
857
|
// before an upload starts. The real ids replace them only after this passes.
|
|
642
858
|
const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
|
|
643
859
|
const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
|
|
644
860
|
if (!preflight.ok) {
|
|
645
|
-
deps
|
|
646
|
-
return EXIT.usage;
|
|
861
|
+
return askFailure(deps, flags, 'invalid_draft', 'draft', preflight.error, 'fix the reported field and retry');
|
|
647
862
|
}
|
|
648
863
|
if ((flags.image?.length ?? 0) > 0) {
|
|
649
864
|
return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
|
|
@@ -688,87 +903,24 @@ export function activeQuestionRouteProblems(deps, active, installations) {
|
|
|
688
903
|
problems.push(...codexTrustProblems(matching, deps.env));
|
|
689
904
|
return [...new Set(problems)];
|
|
690
905
|
}
|
|
691
|
-
function diagnoseActiveHarnessSession(active, problem, installations, contested = []) {
|
|
692
|
-
// Naming one harness confidently is wrong when the environment carries the
|
|
693
|
-
// markers of several and none of them has fired here: whichever one the
|
|
694
|
-
// agent is told to prompt may not be the one it is running in.
|
|
695
|
-
const ambiguity = contested.length > 1
|
|
696
|
-
? [
|
|
697
|
-
`Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
|
|
698
|
-
]
|
|
699
|
-
: [];
|
|
700
|
-
if (problem === 'not-installed') {
|
|
701
|
-
const others = installations.map((installation) => installation.harness);
|
|
702
|
-
return [
|
|
703
|
-
`Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
|
|
704
|
-
...ambiguity,
|
|
705
|
-
...(others.length === 0
|
|
706
|
-
? []
|
|
707
|
-
: [
|
|
708
|
-
`Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
|
|
709
|
-
]),
|
|
710
|
-
`Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
|
|
711
|
-
`Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
|
|
712
|
-
];
|
|
713
|
-
}
|
|
714
|
-
if (problem === 'different-session') {
|
|
715
|
-
return [
|
|
716
|
-
`Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
|
|
717
|
-
...ambiguity,
|
|
718
|
-
`Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
|
|
719
|
-
`Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
|
|
720
|
-
];
|
|
721
|
-
}
|
|
722
|
-
return [
|
|
723
|
-
`Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
|
|
724
|
-
...ambiguity,
|
|
725
|
-
`Send one ${active.label} prompt, then run \`notifai doctor\`.`,
|
|
726
|
-
`Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
|
|
727
|
-
];
|
|
728
|
-
}
|
|
729
|
-
/**
|
|
730
|
-
* Why `ask` cannot see a session, in terms of what to do about it.
|
|
731
|
-
*
|
|
732
|
-
* Only a UserPromptSubmit hook firing produces the pointer this reads, and the
|
|
733
|
-
* old message answered every cause with "run `notifai hooks install` and send
|
|
734
|
-
* one prompt". The useful next action depends on the harness: some reload
|
|
735
|
-
* project hook files, OpenCode loads its plugin at startup, and Codex should be
|
|
736
|
-
* checked after a prompt before assuming that a new session is required.
|
|
737
|
-
*/
|
|
738
|
-
function diagnoseMissingSession(deps) {
|
|
739
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
|
|
740
|
-
if (installations.length === 0) {
|
|
741
|
-
return [
|
|
742
|
-
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
743
|
-
'Run `notifai hooks install`, then follow the activation instruction it prints.',
|
|
744
|
-
];
|
|
745
|
-
}
|
|
746
|
-
const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
|
|
747
|
-
return [
|
|
748
|
-
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
749
|
-
'but no usable session pointer from the last 24 hours exists here.',
|
|
750
|
-
hookActivationAdvice(installations),
|
|
751
|
-
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
752
|
-
];
|
|
753
|
-
}
|
|
754
906
|
/** The least disruptive verified way to make each installed adapter run once. */
|
|
755
907
|
export function hookActivationAdvice(installations) {
|
|
756
908
|
const harnesses = new Set(installations.map((installation) => installation.harness));
|
|
757
909
|
const advice = [];
|
|
758
910
|
if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
|
|
759
|
-
advice.push('Claude Code:
|
|
911
|
+
advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`');
|
|
760
912
|
}
|
|
761
913
|
if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
|
|
762
|
-
advice.push('Claude Code global hooks:
|
|
914
|
+
advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`');
|
|
763
915
|
}
|
|
764
916
|
if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
|
|
765
|
-
advice.push('Cursor: send one prompt, then run `notifai doctor
|
|
917
|
+
advice.push('Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
|
|
766
918
|
}
|
|
767
919
|
if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
|
|
768
|
-
advice.push('Cursor global hooks: send one prompt
|
|
920
|
+
advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
|
|
769
921
|
}
|
|
770
922
|
if (harnesses.has('codex')) {
|
|
771
|
-
advice.push('Codex:
|
|
923
|
+
advice.push('Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`');
|
|
772
924
|
}
|
|
773
925
|
if (harnesses.has('opencode')) {
|
|
774
926
|
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
|
|
@@ -794,10 +946,14 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
794
946
|
return EXIT.usage;
|
|
795
947
|
}
|
|
796
948
|
const logger = log(deps);
|
|
797
|
-
const
|
|
949
|
+
const lifecycleSession = resolveCommandSession(deps, requestId);
|
|
950
|
+
const config = loadLoggedConfig(deps, {
|
|
951
|
+
cwd: deps.cwd,
|
|
952
|
+
env: deps.env,
|
|
953
|
+
...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
|
|
954
|
+
});
|
|
798
955
|
logger.info('acknowledgement.attempted', {
|
|
799
956
|
request_id: requestId,
|
|
800
|
-
text,
|
|
801
957
|
characters: text.length,
|
|
802
958
|
});
|
|
803
959
|
const authed = authedClient(deps, config);
|
|
@@ -814,13 +970,12 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
814
970
|
logger.info('acknowledgement.outcome', {
|
|
815
971
|
request_id: requestId,
|
|
816
972
|
outcome: result.status,
|
|
817
|
-
|
|
973
|
+
text_chars: result.agent_acknowledgement.text.length,
|
|
818
974
|
created_at: result.agent_acknowledgement.created_at,
|
|
819
975
|
agent_acknowledgement_required: true,
|
|
820
976
|
});
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
clearAcknowledgementObligation(sessionId, deps.env, requestId);
|
|
977
|
+
if (lifecycleSession !== null) {
|
|
978
|
+
clearAcknowledgementObligation(lifecycleSession.sessionId, deps.env, requestId);
|
|
824
979
|
}
|
|
825
980
|
if (flags.json)
|
|
826
981
|
deps.io.out(JSON.stringify(output, null, 2));
|
|
@@ -835,12 +990,32 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
835
990
|
}
|
|
836
991
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
837
992
|
export async function closeCommand(deps, requestId, flags = {}) {
|
|
838
|
-
|
|
993
|
+
if (flags.pending === true && requestId !== undefined) {
|
|
994
|
+
deps.io.err('Pass a request id or --pending, not both.');
|
|
995
|
+
return EXIT.usage;
|
|
996
|
+
}
|
|
997
|
+
if (flags.pending !== true && requestId === undefined) {
|
|
998
|
+
deps.io.err('Pass a request id or --pending.');
|
|
999
|
+
return EXIT.usage;
|
|
1000
|
+
}
|
|
1001
|
+
if (flags.pending === true) {
|
|
1002
|
+
return closePendingQuestions(deps, flags.json === true);
|
|
1003
|
+
}
|
|
1004
|
+
const local = await closeLocalQuestion(deps, requestId, flags.json === true);
|
|
1005
|
+
if (local !== null)
|
|
1006
|
+
return local;
|
|
1007
|
+
const lifecycleSession = resolveCommandSession(deps, requestId);
|
|
1008
|
+
const config = loadLoggedConfig(deps, {
|
|
1009
|
+
cwd: deps.cwd,
|
|
1010
|
+
env: deps.env,
|
|
1011
|
+
...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
|
|
1012
|
+
});
|
|
839
1013
|
const authed = authedClient(deps, config);
|
|
840
1014
|
if (!authed)
|
|
841
1015
|
return EXIT.auth;
|
|
842
1016
|
try {
|
|
843
1017
|
const response = await authed.client.closeReplies(requestId);
|
|
1018
|
+
forgetClosedQuestion(deps, requestId);
|
|
844
1019
|
if (flags.json) {
|
|
845
1020
|
deps.io.out(JSON.stringify({
|
|
846
1021
|
...response,
|
|
@@ -858,6 +1033,149 @@ export async function closeCommand(deps, requestId, flags = {}) {
|
|
|
858
1033
|
return reportError(deps, err);
|
|
859
1034
|
}
|
|
860
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Retire this session's outstanding questions, including registrations the
|
|
1038
|
+
* Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
|
|
1039
|
+
* them is the whole retirement. Live ones still need their reply window closed.
|
|
1040
|
+
*/
|
|
1041
|
+
async function closePendingQuestions(deps, json) {
|
|
1042
|
+
const lifecycleSession = resolveCommandSession(deps);
|
|
1043
|
+
if (lifecycleSession === null) {
|
|
1044
|
+
deps.io.err('No active session pointer is available in this directory.');
|
|
1045
|
+
return EXIT.noReply;
|
|
1046
|
+
}
|
|
1047
|
+
const sessionId = lifecycleSession.sessionId;
|
|
1048
|
+
const pending = readSessionState(sessionId, deps.env).pending ?? [];
|
|
1049
|
+
if (pending.length === 0) {
|
|
1050
|
+
if (json) {
|
|
1051
|
+
deps.io.out(JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2));
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
deps.io.err(`Session ${sessionId} has no registered question pending.`);
|
|
1055
|
+
}
|
|
1056
|
+
return EXIT.noReply;
|
|
1057
|
+
}
|
|
1058
|
+
const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env);
|
|
1059
|
+
const live = (readSessionState(sessionId, deps.env).pending ?? []).filter((entry) => entry.request_id !== undefined);
|
|
1060
|
+
const closed = [];
|
|
1061
|
+
if (live.length > 0) {
|
|
1062
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId });
|
|
1063
|
+
const authed = authedClient(deps, config);
|
|
1064
|
+
if (!authed)
|
|
1065
|
+
return EXIT.auth;
|
|
1066
|
+
for (const entry of live) {
|
|
1067
|
+
try {
|
|
1068
|
+
await authed.client.closeReplies(entry.request_id);
|
|
1069
|
+
parkClosedQuestion(sessionId, deps.env, entry);
|
|
1070
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1071
|
+
closed.push(entry.request_id);
|
|
1072
|
+
}
|
|
1073
|
+
catch (err) {
|
|
1074
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const output = {
|
|
1079
|
+
session_id: sessionId,
|
|
1080
|
+
withdrawn: withdrawn.map((entry) => ({
|
|
1081
|
+
question: entry.question,
|
|
1082
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1083
|
+
})),
|
|
1084
|
+
closed,
|
|
1085
|
+
};
|
|
1086
|
+
if (json)
|
|
1087
|
+
deps.io.out(JSON.stringify(output, null, 2));
|
|
1088
|
+
else {
|
|
1089
|
+
if (withdrawn.length > 0) {
|
|
1090
|
+
deps.io.out(withdrawn.length === 1
|
|
1091
|
+
? 'Withdrew 1 unpushed question so a later Stop will not send it.'
|
|
1092
|
+
: `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`);
|
|
1093
|
+
}
|
|
1094
|
+
for (const requestId of closed) {
|
|
1095
|
+
deps.io.out(`Closed the reply window for ${requestId}.`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return EXIT.ok;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Close one outstanding question by the stable id `notifai ask` returned, or
|
|
1102
|
+
* by a request id already on a device. Unpushed entries only exist locally.
|
|
1103
|
+
* Returns null when this directory's session has no matching registration, so
|
|
1104
|
+
* the caller can still close a live request id against the service.
|
|
1105
|
+
*/
|
|
1106
|
+
async function closeLocalQuestion(deps, id, json) {
|
|
1107
|
+
const lifecycleSession = resolveCommandSession(deps, id);
|
|
1108
|
+
if (lifecycleSession === null)
|
|
1109
|
+
return null;
|
|
1110
|
+
const sessionId = lifecycleSession.sessionId;
|
|
1111
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.question_id === id || candidate.request_id === id);
|
|
1112
|
+
if (entry === undefined)
|
|
1113
|
+
return null;
|
|
1114
|
+
if (entry.request_id === undefined) {
|
|
1115
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1116
|
+
const output = {
|
|
1117
|
+
session_id: sessionId,
|
|
1118
|
+
withdrawn: [
|
|
1119
|
+
{
|
|
1120
|
+
question: entry.question,
|
|
1121
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1122
|
+
},
|
|
1123
|
+
],
|
|
1124
|
+
closed: [],
|
|
1125
|
+
};
|
|
1126
|
+
if (json)
|
|
1127
|
+
deps.io.out(JSON.stringify(output, null, 2));
|
|
1128
|
+
else {
|
|
1129
|
+
deps.io.out(`Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`);
|
|
1130
|
+
}
|
|
1131
|
+
return EXIT.ok;
|
|
1132
|
+
}
|
|
1133
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId });
|
|
1134
|
+
const authed = authedClient(deps, config);
|
|
1135
|
+
if (!authed)
|
|
1136
|
+
return EXIT.auth;
|
|
1137
|
+
try {
|
|
1138
|
+
const response = await authed.client.closeReplies(entry.request_id);
|
|
1139
|
+
parkClosedQuestion(sessionId, deps.env, entry);
|
|
1140
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1141
|
+
if (json) {
|
|
1142
|
+
deps.io.out(JSON.stringify({
|
|
1143
|
+
...response,
|
|
1144
|
+
question_id: entry.question_id,
|
|
1145
|
+
acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
|
|
1146
|
+
}, null, 2));
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
deps.io.out(`Closed the reply window for ${entry.request_id}.`);
|
|
1150
|
+
if (response.replies.length > 0)
|
|
1151
|
+
printAcknowledgementStatus(deps, response);
|
|
1152
|
+
}
|
|
1153
|
+
return EXIT.ok;
|
|
1154
|
+
}
|
|
1155
|
+
catch (err) {
|
|
1156
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
function forgetClosedQuestion(deps, requestId) {
|
|
1160
|
+
const lifecycleSession = resolveCommandSession(deps, requestId);
|
|
1161
|
+
if (lifecycleSession === null)
|
|
1162
|
+
return;
|
|
1163
|
+
const sessionId = lifecycleSession.sessionId;
|
|
1164
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.request_id === requestId);
|
|
1165
|
+
if (entry === undefined)
|
|
1166
|
+
return;
|
|
1167
|
+
parkClosedQuestion(sessionId, deps.env, entry);
|
|
1168
|
+
dropPendingQuestion(sessionId, deps.env, entry);
|
|
1169
|
+
}
|
|
1170
|
+
function parkClosedQuestion(sessionId, env, entry) {
|
|
1171
|
+
try {
|
|
1172
|
+
parkForRetirement(sessionId, env, entry, 'expired');
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
// Incomplete identifiers cannot be retired on a device; dropping the
|
|
1176
|
+
// local record is still the right way to stop a later Stop from waiting.
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
861
1179
|
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
862
1180
|
export function runningViaNpx(env, scriptPath) {
|
|
863
1181
|
if (env['npm_command'] === 'exec')
|
|
@@ -891,12 +1209,19 @@ function resolveHookAdapterTarget(deps, flags) {
|
|
|
891
1209
|
}
|
|
892
1210
|
function printHooksInstallClose(deps, harness, file) {
|
|
893
1211
|
const label = HARNESS_LABELS[harness];
|
|
1212
|
+
const activation = harness === 'codex'
|
|
1213
|
+
? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
|
|
1214
|
+
: harness === 'cursor'
|
|
1215
|
+
? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
|
|
1216
|
+
: harness === 'opencode'
|
|
1217
|
+
? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
|
|
1218
|
+
: `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`;
|
|
894
1219
|
if (deps.io.interactive === true && deps.io.note) {
|
|
895
|
-
void deps.io.note(`${file}\
|
|
1220
|
+
void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`);
|
|
896
1221
|
return;
|
|
897
1222
|
}
|
|
898
1223
|
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
899
|
-
deps.io.out(
|
|
1224
|
+
deps.io.out(activation);
|
|
900
1225
|
}
|
|
901
1226
|
export function hooksInstallCommand(deps, flags) {
|
|
902
1227
|
if (flags.harness === undefined) {
|
|
@@ -931,7 +1256,18 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
931
1256
|
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
|
|
932
1257
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
|
|
933
1258
|
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
934
|
-
const
|
|
1259
|
+
const globalInstallations = otherScope.filter((installation) => installation.global);
|
|
1260
|
+
const globalInstallation = globalInstallations[0];
|
|
1261
|
+
const installedEvents = new Set(globalInstallation?.handlers
|
|
1262
|
+
.map((handler) => handlerEvent(handler.command))
|
|
1263
|
+
.filter((event) => event !== null && HOOK_EVENTS.includes(event)) ?? []);
|
|
1264
|
+
if (globalInstallations.length !== 1 ||
|
|
1265
|
+
globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
|
|
1266
|
+
globalInstallations.some((installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0) ||
|
|
1267
|
+
!requiredHookEvents(harness).every((event) => installedEvents.has(event))) {
|
|
1268
|
+
return hooksInstallCommand(deps, { ...flags, global: true, harness });
|
|
1269
|
+
}
|
|
1270
|
+
const globalFile = globalInstallation?.file;
|
|
935
1271
|
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
1272
|
return EXIT.ok;
|
|
937
1273
|
}
|
|
@@ -992,7 +1328,21 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
992
1328
|
installed =
|
|
993
1329
|
codexPaths === null
|
|
994
1330
|
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
995
|
-
: withCodexLayerTransaction(codexPaths, (inspection) =>
|
|
1331
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1332
|
+
const staleTarget = inspection.writeTarget === inspection.paths.hooksJson
|
|
1333
|
+
? inspection.paths.configToml
|
|
1334
|
+
: inspection.paths.hooksJson;
|
|
1335
|
+
const staleEvents = staleTarget === inspection.paths.hooksJson
|
|
1336
|
+
? inspection.ourJsonEvents
|
|
1337
|
+
: inspection.ourTomlEvents;
|
|
1338
|
+
if (staleEvents.length > 0) {
|
|
1339
|
+
const staleDocument = loadSettings(staleTarget);
|
|
1340
|
+
const stripped = removeHooks(staleDocument, scriptPath);
|
|
1341
|
+
if (stripped.replaced.length > 0)
|
|
1342
|
+
applyPlan(staleTarget, stripped.document);
|
|
1343
|
+
}
|
|
1344
|
+
return installInto(inspection.writeTarget);
|
|
1345
|
+
});
|
|
996
1346
|
}
|
|
997
1347
|
catch (err) {
|
|
998
1348
|
deps.io.err(String(err));
|
|
@@ -1028,7 +1378,7 @@ function foreignStopHandlers(document) {
|
|
|
1028
1378
|
return [];
|
|
1029
1379
|
return groups
|
|
1030
1380
|
.flatMap((group) => group.hooks ?? [])
|
|
1031
|
-
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
1381
|
+
.filter((handler) => !/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
1032
1382
|
}
|
|
1033
1383
|
/**
|
|
1034
1384
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
@@ -1146,7 +1496,11 @@ export function hooksUninstallCommand(deps, flags) {
|
|
|
1146
1496
|
result =
|
|
1147
1497
|
codexPaths === null
|
|
1148
1498
|
? removeInstalledHooks()
|
|
1149
|
-
: withCodexLayerTransaction(codexPaths,
|
|
1499
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1500
|
+
const stripped = removeInstalledHooks();
|
|
1501
|
+
cleanupEmptiedCodexLayer(inspection.paths);
|
|
1502
|
+
return stripped;
|
|
1503
|
+
});
|
|
1150
1504
|
}
|
|
1151
1505
|
catch (err) {
|
|
1152
1506
|
deps.io.err(String(err));
|