@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/src/commands-hooks.ts
CHANGED
|
@@ -30,19 +30,26 @@ import {
|
|
|
30
30
|
} from './hook-adapter.js'
|
|
31
31
|
import {
|
|
32
32
|
clearAcknowledgementObligation,
|
|
33
|
+
claimPromptActivation,
|
|
34
|
+
claimCursorStopActivation,
|
|
35
|
+
confirmCursorStopActivation,
|
|
36
|
+
dropPendingQuestion,
|
|
33
37
|
handleSessionEnd,
|
|
34
38
|
handleStop,
|
|
35
39
|
handleUserPromptSubmit,
|
|
40
|
+
markSessionActivation,
|
|
41
|
+
parkForRetirement,
|
|
36
42
|
parseHookInput,
|
|
37
43
|
pruneAbandonedSessions,
|
|
38
|
-
readMatchingProjectSessionPointer,
|
|
39
|
-
readProjectSession,
|
|
40
44
|
readSessionState,
|
|
45
|
+
resetCursorStopActivation,
|
|
41
46
|
registerQuestion,
|
|
42
47
|
waiterCeilingSeconds,
|
|
48
|
+
withdrawUnpushedQuestions,
|
|
43
49
|
type EscalationDeliveryRoute,
|
|
44
50
|
type HookContext,
|
|
45
51
|
type HookHarness,
|
|
52
|
+
type PendingQuestion,
|
|
46
53
|
} from './hooks.js'
|
|
47
54
|
import {
|
|
48
55
|
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
@@ -50,6 +57,7 @@ import {
|
|
|
50
57
|
applyPlan,
|
|
51
58
|
buildCursorHookConfig,
|
|
52
59
|
buildHookConfig,
|
|
60
|
+
cleanupEmptiedCodexLayer,
|
|
53
61
|
codexCoexistenceNotes,
|
|
54
62
|
codexHomeNote,
|
|
55
63
|
codexLayerDir,
|
|
@@ -76,6 +84,11 @@ import {
|
|
|
76
84
|
import { logConfigResolved, logSettingsFrom } from './logging.js'
|
|
77
85
|
import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
|
|
78
86
|
import { packageVersion } from './release.js'
|
|
87
|
+
import { rejectAccidentalEscapedNewlines } from './send.js'
|
|
88
|
+
import {
|
|
89
|
+
cursorStopActivationOutput,
|
|
90
|
+
sessionActivationOutput,
|
|
91
|
+
} from './session-activation.js'
|
|
79
92
|
import {
|
|
80
93
|
CHOICE_USAGE,
|
|
81
94
|
buildDraft,
|
|
@@ -87,6 +100,7 @@ import {
|
|
|
87
100
|
import {
|
|
88
101
|
EXIT,
|
|
89
102
|
authedClient,
|
|
103
|
+
diagnoseIgnoredOriginOverride,
|
|
90
104
|
loadLoggedConfig,
|
|
91
105
|
log,
|
|
92
106
|
makeClient,
|
|
@@ -107,14 +121,30 @@ import {
|
|
|
107
121
|
uploadImage,
|
|
108
122
|
waitForReply,
|
|
109
123
|
} from './commands-send-support.js'
|
|
124
|
+
import { resolveCommandSession } from './command-session.js'
|
|
110
125
|
|
|
111
126
|
// ---------------------------------------------------------------------------
|
|
112
127
|
// hook / ask / close — harness integration
|
|
113
128
|
// ---------------------------------------------------------------------------
|
|
114
129
|
|
|
115
|
-
export const HOOK_EVENTS = [
|
|
130
|
+
export const HOOK_EVENTS = [
|
|
131
|
+
'session-start',
|
|
132
|
+
'subagent-start',
|
|
133
|
+
'activation-stop',
|
|
134
|
+
'user-prompt-submit',
|
|
135
|
+
'stop',
|
|
136
|
+
'session-end',
|
|
137
|
+
] as const
|
|
116
138
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
117
139
|
|
|
140
|
+
/** Lifecycle handlers one installed harness must carry in this CLI build. */
|
|
141
|
+
export function requiredHookEvents(harness: Harness): readonly HookEvent[] {
|
|
142
|
+
if (harness === 'opencode') return []
|
|
143
|
+
return harness === 'cursor'
|
|
144
|
+
? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
|
|
145
|
+
: ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end']
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
119
149
|
export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
120
150
|
event: unknown,
|
|
@@ -124,8 +154,8 @@ export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
|
124
154
|
|
|
125
155
|
/**
|
|
126
156
|
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
127
|
-
* stdin,
|
|
128
|
-
* exit 0 with no stdout means "no decision — carry on as normal".
|
|
157
|
+
* stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
|
|
158
|
+
* exit 0 with no stdout means "no decision or added context — carry on as normal".
|
|
129
159
|
*
|
|
130
160
|
* Every failure path in here must reach that no-decision state. A hook that
|
|
131
161
|
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
@@ -166,6 +196,21 @@ export async function hookRunCommand(
|
|
|
166
196
|
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
167
197
|
: { message: err instanceof Error ? err.message : String(err) }
|
|
168
198
|
|
|
199
|
+
// Cursor may load ~/.claude/settings.json in addition to its own native
|
|
200
|
+
// hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
|
|
201
|
+
// compatibility copy becomes a no-op and the native Cursor definition is
|
|
202
|
+
// the single owner. Real Claude hooks do not receive that hook-only marker.
|
|
203
|
+
if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
|
|
204
|
+
start({ outcome: 'cursor-compatibility-copy-skipped' })
|
|
205
|
+
logger.info('hook.end', {
|
|
206
|
+
hook: event,
|
|
207
|
+
outcome: 'ignored',
|
|
208
|
+
reason: 'cursor-native-handler-owns-event',
|
|
209
|
+
decided: false,
|
|
210
|
+
})
|
|
211
|
+
return EXIT.ok
|
|
212
|
+
}
|
|
213
|
+
|
|
169
214
|
let raw: string
|
|
170
215
|
try {
|
|
171
216
|
raw = await readStdin()
|
|
@@ -209,6 +254,174 @@ export async function hookRunCommand(
|
|
|
209
254
|
const cwd = envelope.cwd ?? deps.cwd
|
|
210
255
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
211
256
|
logger.bind({ session: envelope.session_id ?? null })
|
|
257
|
+
const declaredSourcePid = declaredHookSourcePid(deps)
|
|
258
|
+
const activationOwnerPid =
|
|
259
|
+
harness === 'claude-code'
|
|
260
|
+
? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
|
|
261
|
+
: harness === 'codex'
|
|
262
|
+
? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
|
|
263
|
+
: undefined
|
|
264
|
+
|
|
265
|
+
// Session activation is local model context, not authenticated routing. It
|
|
266
|
+
// must survive the exact first-run states it exists to repair: no config,
|
|
267
|
+
// no credential, no Companion device, and no network.
|
|
268
|
+
if (event === 'session-start' || event === 'subagent-start') {
|
|
269
|
+
start({ cwd, source: envelope.source ?? null })
|
|
270
|
+
if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
|
|
271
|
+
try {
|
|
272
|
+
resetCursorStopActivation(envelope.session_id, deps.env)
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.error('hook.end', {
|
|
275
|
+
hook: event,
|
|
276
|
+
outcome: 'reset-failed',
|
|
277
|
+
...failureData(err),
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const stdout = sessionActivationOutput(
|
|
282
|
+
harness,
|
|
283
|
+
event === 'session-start' ? 'SessionStart' : 'SubagentStart',
|
|
284
|
+
cwd,
|
|
285
|
+
deps.env,
|
|
286
|
+
)
|
|
287
|
+
if (stdout !== undefined) deps.io.out(stdout)
|
|
288
|
+
if (
|
|
289
|
+
stdout !== undefined &&
|
|
290
|
+
event === 'session-start' &&
|
|
291
|
+
activationOwnerPid !== undefined &&
|
|
292
|
+
envelope.session_id !== undefined
|
|
293
|
+
) {
|
|
294
|
+
try {
|
|
295
|
+
// Write stdout first. If the process dies between these operations a
|
|
296
|
+
// later prompt may duplicate context, but it cannot suppress context
|
|
297
|
+
// that the host never received.
|
|
298
|
+
markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)
|
|
299
|
+
} catch (err) {
|
|
300
|
+
logger.error('hook.end', {
|
|
301
|
+
hook: event,
|
|
302
|
+
outcome: 'mark-failed',
|
|
303
|
+
reason: 'activation-state-failed',
|
|
304
|
+
...failureData(err),
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
logger.info('hook.end', {
|
|
309
|
+
hook: event,
|
|
310
|
+
outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
|
|
311
|
+
decided: false,
|
|
312
|
+
})
|
|
313
|
+
return EXIT.ok
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// SessionStart is the preferred model-visible activation seam. A few
|
|
317
|
+
// managed hosts regenerate older hook definitions that retain
|
|
318
|
+
// UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
|
|
319
|
+
// once per exact session as a compatibility fallback, before config/auth/
|
|
320
|
+
// device/network work, and stay silent when SessionStart already ran.
|
|
321
|
+
let promptActivationStdout: string | undefined
|
|
322
|
+
if (
|
|
323
|
+
event === 'user-prompt-submit' &&
|
|
324
|
+
(harness === 'claude-code' || harness === 'codex') &&
|
|
325
|
+
activationOwnerPid !== undefined &&
|
|
326
|
+
envelope.session_id !== undefined
|
|
327
|
+
) {
|
|
328
|
+
try {
|
|
329
|
+
if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)) {
|
|
330
|
+
promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit', cwd, deps.env)
|
|
331
|
+
if (promptActivationStdout !== undefined) deps.io.out(promptActivationStdout)
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
logger.error('hook.end', {
|
|
335
|
+
hook: event,
|
|
336
|
+
outcome: 'claim-failed',
|
|
337
|
+
reason: 'activation-state-failed',
|
|
338
|
+
...failureData(err),
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Cursor has a confirmed host bug in which sessionStart.additional_context
|
|
344
|
+
// is accepted but never reaches the model. A native Stop follow-up is the
|
|
345
|
+
// host's guaranteed model-visible channel. Claim it once per conversation,
|
|
346
|
+
// before config/auth/network, and leave the ordinary Stop handler separate
|
|
347
|
+
// so question delivery is never displaced by activation.
|
|
348
|
+
if (event === 'activation-stop') {
|
|
349
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
350
|
+
if (harness !== 'cursor') {
|
|
351
|
+
logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false })
|
|
352
|
+
return EXIT.ok
|
|
353
|
+
}
|
|
354
|
+
if (envelope.status === 'aborted') {
|
|
355
|
+
logger.info('hook.end', {
|
|
356
|
+
hook: event,
|
|
357
|
+
outcome: 'ignored',
|
|
358
|
+
reason: `turn-${envelope.status}`,
|
|
359
|
+
decided: false,
|
|
360
|
+
})
|
|
361
|
+
return EXIT.ok
|
|
362
|
+
}
|
|
363
|
+
if (envelope.session_id === undefined) {
|
|
364
|
+
logger.info('hook.end', {
|
|
365
|
+
hook: event,
|
|
366
|
+
outcome: 'ignored',
|
|
367
|
+
reason: 'missing-conversation-id',
|
|
368
|
+
decided: false,
|
|
369
|
+
})
|
|
370
|
+
return EXIT.ok
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
envelope.session_id !== undefined &&
|
|
374
|
+
typeof envelope.loop_count === 'number' &&
|
|
375
|
+
envelope.loop_count > 0
|
|
376
|
+
) {
|
|
377
|
+
let activationOwned = false
|
|
378
|
+
try {
|
|
379
|
+
activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now())
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.error('hook.end', {
|
|
382
|
+
hook: event,
|
|
383
|
+
outcome: 'confirm-failed',
|
|
384
|
+
...failureData(err),
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
if (activationOwned) {
|
|
388
|
+
logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false })
|
|
389
|
+
return EXIT.ok
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const cursorState = readSessionState(envelope.session_id, deps.env)
|
|
393
|
+
if (
|
|
394
|
+
(cursorState.pending?.length ?? 0) > 0 ||
|
|
395
|
+
cursorState.accepted !== undefined ||
|
|
396
|
+
(cursorState.acknowledgement_due?.length ?? 0) > 0
|
|
397
|
+
) {
|
|
398
|
+
logger.info('hook.end', {
|
|
399
|
+
hook: event,
|
|
400
|
+
outcome: 'deferred',
|
|
401
|
+
reason: 'question-continuation-owns-stop',
|
|
402
|
+
decided: false,
|
|
403
|
+
})
|
|
404
|
+
return EXIT.ok
|
|
405
|
+
}
|
|
406
|
+
let claimed = false
|
|
407
|
+
try {
|
|
408
|
+
claimed = claimCursorStopActivation(envelope.session_id, deps.env, now())
|
|
409
|
+
} catch (err) {
|
|
410
|
+
logger.error('hook.end', {
|
|
411
|
+
hook: event,
|
|
412
|
+
outcome: 'claim-failed',
|
|
413
|
+
...failureData(err),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
logger.info('hook.end', {
|
|
417
|
+
hook: event,
|
|
418
|
+
outcome: claimed ? 'followup-added' : 'already-activated',
|
|
419
|
+
decided: claimed,
|
|
420
|
+
})
|
|
421
|
+
if (claimed) deps.io.out(cursorStopActivationOutput(cwd, deps.env))
|
|
422
|
+
return EXIT.ok
|
|
423
|
+
}
|
|
424
|
+
|
|
212
425
|
let config: CliConfig | null = null
|
|
213
426
|
let configFailure: unknown
|
|
214
427
|
try {
|
|
@@ -230,6 +443,7 @@ export async function hookRunCommand(
|
|
|
230
443
|
hook: event,
|
|
231
444
|
outcome: 'failed',
|
|
232
445
|
reason: 'config-failed',
|
|
446
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
233
447
|
...failureData(err),
|
|
234
448
|
})
|
|
235
449
|
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
@@ -275,11 +489,7 @@ export async function hookRunCommand(
|
|
|
275
489
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
276
490
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
277
491
|
const baseUrl = credential.baseUrl
|
|
278
|
-
|
|
279
|
-
deps.io.err(
|
|
280
|
-
`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`,
|
|
281
|
-
)
|
|
282
|
-
}
|
|
492
|
+
diagnoseIgnoredOriginOverride(deps.io, resolved, credential)
|
|
283
493
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
284
494
|
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
285
495
|
// Stop is allowed to block and keeps the ordinary budget.
|
|
@@ -337,9 +547,10 @@ export async function hookRunCommand(
|
|
|
337
547
|
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
338
548
|
logger.info('hook.end', {
|
|
339
549
|
hook: event,
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
decided: outcome.stdout !== undefined,
|
|
550
|
+
// Stop stdout takes over the turn. UserPromptSubmit stdout may instead
|
|
551
|
+
// add context to the user's own turn, which is explicitly non-decisive.
|
|
552
|
+
decided: outcome.decided ?? outcome.stdout !== undefined,
|
|
553
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
343
554
|
...(notes.length === 0 ? {} : { notes }),
|
|
344
555
|
...outcome.log,
|
|
345
556
|
})
|
|
@@ -379,12 +590,13 @@ function stopWakeRoute(
|
|
|
379
590
|
cwd: string,
|
|
380
591
|
): EscalationDeliveryRoute | undefined {
|
|
381
592
|
if (sessionId === undefined) return undefined
|
|
593
|
+
const declaredSourcePid = declaredHookSourcePid(deps)
|
|
382
594
|
if (harness === 'claude-code') {
|
|
383
595
|
if ((deps.hookPlatform ?? process.platform) === 'win32') return undefined
|
|
384
596
|
return claudeWakeRoute({
|
|
385
597
|
sessionId,
|
|
386
598
|
cwd,
|
|
387
|
-
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
599
|
+
sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
|
|
388
600
|
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
389
601
|
})
|
|
390
602
|
}
|
|
@@ -392,7 +604,7 @@ function stopWakeRoute(
|
|
|
392
604
|
return codexWakeRoute({
|
|
393
605
|
threadId: sessionId,
|
|
394
606
|
cwd,
|
|
395
|
-
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
607
|
+
sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
|
|
396
608
|
env: deps.env,
|
|
397
609
|
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
398
610
|
})
|
|
@@ -400,6 +612,12 @@ function stopWakeRoute(
|
|
|
400
612
|
return undefined
|
|
401
613
|
}
|
|
402
614
|
|
|
615
|
+
/** Stable harness parent propagated by the managed adapter across child tools. */
|
|
616
|
+
function declaredHookSourcePid(deps: CommandDeps): number | undefined {
|
|
617
|
+
const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID'])
|
|
618
|
+
return Number.isInteger(value) && value > 0 ? value : undefined
|
|
619
|
+
}
|
|
620
|
+
|
|
403
621
|
|
|
404
622
|
/**
|
|
405
623
|
* What went wrong, in terms of what to do about it.
|
|
@@ -442,15 +660,59 @@ export interface AskFlags {
|
|
|
442
660
|
multi?: boolean
|
|
443
661
|
/** Optional Markdown context appended after the question block. */
|
|
444
662
|
body?: string
|
|
663
|
+
/** Allow visible backslash-n sequences in `--body`. */
|
|
664
|
+
literalBackslashN?: boolean
|
|
445
665
|
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
446
666
|
form?: string
|
|
447
667
|
image?: string[]
|
|
448
668
|
imageAlt?: string[]
|
|
449
669
|
project?: string
|
|
450
|
-
sessionId?: string
|
|
451
670
|
sessionLabel?: string
|
|
452
671
|
}
|
|
453
672
|
|
|
673
|
+
export interface AskFailure {
|
|
674
|
+
ok: false
|
|
675
|
+
registered: false
|
|
676
|
+
code: string
|
|
677
|
+
check_id: string
|
|
678
|
+
exit_code: number
|
|
679
|
+
remedy: string
|
|
680
|
+
message: string
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** One stable failure contract for every machine-readable ask refusal. */
|
|
684
|
+
export function reportAskFailure(
|
|
685
|
+
deps: CommandDeps,
|
|
686
|
+
flags: Pick<AskFlags, 'json'>,
|
|
687
|
+
failure: Omit<AskFailure, 'ok' | 'registered'>,
|
|
688
|
+
): number {
|
|
689
|
+
if (flags.json === true) {
|
|
690
|
+
deps.io.out(JSON.stringify({ ok: false, registered: false, ...failure }, null, 2))
|
|
691
|
+
} else {
|
|
692
|
+
deps.io.err(failure.message)
|
|
693
|
+
deps.io.err(`next: ${failure.remedy}`)
|
|
694
|
+
}
|
|
695
|
+
return failure.exit_code
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function askFailure(
|
|
699
|
+
deps: CommandDeps,
|
|
700
|
+
flags: AskFlags,
|
|
701
|
+
code: string,
|
|
702
|
+
checkId: string,
|
|
703
|
+
message: string,
|
|
704
|
+
remedy: string,
|
|
705
|
+
exitCode: number = EXIT.usage,
|
|
706
|
+
): number {
|
|
707
|
+
return reportAskFailure(deps, flags, {
|
|
708
|
+
code,
|
|
709
|
+
check_id: checkId,
|
|
710
|
+
exit_code: exitCode,
|
|
711
|
+
remedy,
|
|
712
|
+
message,
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
|
|
454
716
|
/** The `--form` document: what an agent writes to ask several things at once. */
|
|
455
717
|
interface AskFormQuestion {
|
|
456
718
|
text: string
|
|
@@ -627,8 +889,9 @@ function recordRegisteredQuestion(
|
|
|
627
889
|
draft: NotificationDraftT,
|
|
628
890
|
json = false,
|
|
629
891
|
): number {
|
|
892
|
+
let questionId: string
|
|
630
893
|
try {
|
|
631
|
-
registerQuestion(
|
|
894
|
+
questionId = registerQuestion(
|
|
632
895
|
sessionId,
|
|
633
896
|
deps.env,
|
|
634
897
|
{
|
|
@@ -643,14 +906,22 @@ function recordRegisteredQuestion(
|
|
|
643
906
|
)
|
|
644
907
|
} catch (err) {
|
|
645
908
|
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
646
|
-
|
|
647
|
-
|
|
909
|
+
return askFailure(
|
|
910
|
+
deps,
|
|
911
|
+
{ json },
|
|
912
|
+
'registration_failed',
|
|
913
|
+
'question_registration',
|
|
914
|
+
`Could not register the question: ${err instanceof Error ? err.message : String(err)}`,
|
|
915
|
+
'retire an older pending question if the limit was reached, then retry the same ask',
|
|
916
|
+
EXIT.failed,
|
|
917
|
+
)
|
|
648
918
|
}
|
|
649
919
|
log(deps).info('ask.registered', {
|
|
650
920
|
ok: true,
|
|
651
921
|
session: sessionId,
|
|
922
|
+
question_id: questionId,
|
|
652
923
|
questions: built.questions.length,
|
|
653
|
-
|
|
924
|
+
text_chars: built.questions[0]!.text.length,
|
|
654
925
|
choices: built.questions[0]!.choices?.length ?? 0,
|
|
655
926
|
media: draft.presentation.media?.length ?? 0,
|
|
656
927
|
})
|
|
@@ -663,12 +934,14 @@ function recordRegisteredQuestion(
|
|
|
663
934
|
JSON.stringify(
|
|
664
935
|
{
|
|
665
936
|
registered: true,
|
|
937
|
+
question_id: questionId,
|
|
666
938
|
questions: built.questions.map((entry) => ({
|
|
667
939
|
id: entry.id,
|
|
668
940
|
text: entry.text,
|
|
669
941
|
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
670
942
|
...(entry.multi === true ? { multi: true } : {}),
|
|
671
943
|
})),
|
|
944
|
+
close: `notifai close ${questionId}`,
|
|
672
945
|
next: {
|
|
673
946
|
end_turn: true,
|
|
674
947
|
in_this_turn:
|
|
@@ -677,6 +950,7 @@ function recordRegisteredQuestion(
|
|
|
677
950
|
'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
678
951
|
on_answer:
|
|
679
952
|
'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
953
|
+
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.`,
|
|
680
954
|
},
|
|
681
955
|
},
|
|
682
956
|
null,
|
|
@@ -696,8 +970,8 @@ function recordRegisteredQuestion(
|
|
|
696
970
|
}
|
|
697
971
|
deps.io.out(
|
|
698
972
|
built.questions.length > 1
|
|
699
|
-
? `${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.`
|
|
700
|
-
:
|
|
973
|
+
? `${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.`
|
|
974
|
+
: `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`,
|
|
701
975
|
)
|
|
702
976
|
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
|
|
703
977
|
for (const [index, entry] of built.questions.entries()) {
|
|
@@ -723,6 +997,9 @@ function recordRegisteredQuestion(
|
|
|
723
997
|
deps.io.out(
|
|
724
998
|
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
725
999
|
)
|
|
1000
|
+
deps.io.out(
|
|
1001
|
+
`If they answer in this conversation instead, retire it with \`notifai close ${questionId}\` so a later Stop will not send it.`,
|
|
1002
|
+
)
|
|
726
1003
|
return EXIT.ok
|
|
727
1004
|
}
|
|
728
1005
|
|
|
@@ -735,24 +1012,41 @@ async function uploadAskMedia(
|
|
|
735
1012
|
invocation: DraftInvocation,
|
|
736
1013
|
): Promise<number> {
|
|
737
1014
|
const authed = authedClient(deps, config)
|
|
738
|
-
if (!authed)
|
|
1015
|
+
if (!authed) {
|
|
1016
|
+
return askFailure(
|
|
1017
|
+
deps,
|
|
1018
|
+
flags,
|
|
1019
|
+
'auth_required',
|
|
1020
|
+
'credential',
|
|
1021
|
+
'Question routing is not paired on this machine.',
|
|
1022
|
+
'run `notifai init --json`',
|
|
1023
|
+
EXIT.auth,
|
|
1024
|
+
)
|
|
1025
|
+
}
|
|
739
1026
|
const mediaIds: string[] = []
|
|
740
1027
|
for (const image of flags.image ?? []) {
|
|
741
1028
|
if (image.startsWith('med_')) {
|
|
742
1029
|
mediaIds.push(image)
|
|
743
1030
|
continue
|
|
744
1031
|
}
|
|
745
|
-
const uploaded = await uploadImage(deps, authed.client, image)
|
|
1032
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
746
1033
|
if (!uploaded.ok) {
|
|
747
1034
|
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
748
|
-
return
|
|
1035
|
+
return askFailure(
|
|
1036
|
+
deps,
|
|
1037
|
+
flags,
|
|
1038
|
+
'media_upload_failed',
|
|
1039
|
+
'media',
|
|
1040
|
+
uploaded.error ?? 'The image upload failed.',
|
|
1041
|
+
'fix the reported image or network problem, then retry the same ask',
|
|
1042
|
+
uploaded.exit,
|
|
1043
|
+
)
|
|
749
1044
|
}
|
|
750
1045
|
mediaIds.push(uploaded.mediaId)
|
|
751
1046
|
}
|
|
752
1047
|
const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
|
|
753
1048
|
if (!ready.ok) {
|
|
754
|
-
deps
|
|
755
|
-
return EXIT.usage
|
|
1049
|
+
return askFailure(deps, flags, 'invalid_draft', 'draft', ready.error, 'fix the reported field and retry')
|
|
756
1050
|
}
|
|
757
1051
|
return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
|
|
758
1052
|
}
|
|
@@ -770,28 +1064,28 @@ export function askCommand(
|
|
|
770
1064
|
// Validate before route discovery. A malformed question belongs to the
|
|
771
1065
|
// caller and should not be hidden behind whichever harness setup issue
|
|
772
1066
|
// happens to exist on this machine.
|
|
1067
|
+
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
|
|
1068
|
+
if (escapedBody !== null) {
|
|
1069
|
+
return askFailure(deps, flags, 'invalid_input', 'body', escapedBody, 'fix the body and retry')
|
|
1070
|
+
}
|
|
773
1071
|
const built = buildQuestions(flags, question)
|
|
774
1072
|
if (!built.ok) {
|
|
775
|
-
deps
|
|
776
|
-
return EXIT.usage
|
|
1073
|
+
return askFailure(deps, flags, 'invalid_input', 'questions', built.error, 'fix the question input and retry')
|
|
777
1074
|
}
|
|
778
1075
|
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
779
1076
|
if (mediaInputError !== null) {
|
|
780
|
-
deps
|
|
781
|
-
return EXIT.usage
|
|
782
|
-
}
|
|
783
|
-
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
784
|
-
if (!routingConfig.ask_notifications.value) {
|
|
785
|
-
deps.io.err(
|
|
786
|
-
'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
|
|
787
|
-
)
|
|
788
|
-
return EXIT.usage
|
|
1077
|
+
return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry')
|
|
789
1078
|
}
|
|
790
1079
|
if (deps.store.load() === null) {
|
|
791
|
-
|
|
792
|
-
|
|
1080
|
+
return askFailure(
|
|
1081
|
+
deps,
|
|
1082
|
+
flags,
|
|
1083
|
+
'auth_required',
|
|
1084
|
+
'credential',
|
|
1085
|
+
'Question routing is not paired on this machine.',
|
|
1086
|
+
'run `notifai init --json`',
|
|
1087
|
+
EXIT.auth,
|
|
793
1088
|
)
|
|
794
|
-
return EXIT.usage
|
|
795
1089
|
}
|
|
796
1090
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
797
1091
|
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
@@ -800,78 +1094,127 @@ export function askCommand(
|
|
|
800
1094
|
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
|
|
801
1095
|
let sessionId: string | undefined
|
|
802
1096
|
if (active !== null) {
|
|
803
|
-
|
|
1097
|
+
if (contested.length > 1) {
|
|
1098
|
+
return askFailure(
|
|
1099
|
+
deps,
|
|
1100
|
+
flags,
|
|
1101
|
+
'session_identity_ambiguous',
|
|
1102
|
+
'exact_session',
|
|
1103
|
+
`Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}).`,
|
|
1104
|
+
'run the ask from a shell with one exact active harness session',
|
|
1105
|
+
)
|
|
1106
|
+
}
|
|
1107
|
+
const exactState = active.sessionId === undefined
|
|
1108
|
+
? null
|
|
1109
|
+
: readSessionState(active.sessionId, deps.env)
|
|
1110
|
+
const installationCwd = exactState?.activation_cwd ?? deps.cwd
|
|
1111
|
+
const installationDeps = installationCwd === deps.cwd ? deps : { ...deps, cwd: installationCwd }
|
|
1112
|
+
const installations = findInstallations(installationCwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
804
1113
|
const activeInstalled = installations.some(
|
|
805
1114
|
(installation) => installation.harness === active.harness,
|
|
806
1115
|
)
|
|
807
1116
|
if (!activeInstalled) {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return EXIT.usage
|
|
1117
|
+
return askFailure(
|
|
1118
|
+
deps,
|
|
1119
|
+
flags,
|
|
1120
|
+
'hooks_not_installed',
|
|
1121
|
+
'hook_installation',
|
|
1122
|
+
`Notifai ${active.label} hooks are not installed for this project.`,
|
|
1123
|
+
`run \`notifai init --json\` from this ${active.label} session`,
|
|
1124
|
+
)
|
|
817
1125
|
}
|
|
818
1126
|
if (active.sessionId === undefined) {
|
|
819
|
-
|
|
820
|
-
deps
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
now,
|
|
828
|
-
active.sessionId,
|
|
829
|
-
active.harness,
|
|
830
|
-
)
|
|
831
|
-
if (projectPointer === null) {
|
|
832
|
-
for (const line of diagnoseActiveHarnessSession(
|
|
833
|
-
active,
|
|
834
|
-
'not-fired',
|
|
835
|
-
installations,
|
|
836
|
-
contested,
|
|
837
|
-
)) {
|
|
838
|
-
deps.io.err(line)
|
|
839
|
-
}
|
|
840
|
-
return EXIT.usage
|
|
1127
|
+
return askFailure(
|
|
1128
|
+
deps,
|
|
1129
|
+
flags,
|
|
1130
|
+
'session_identity_missing',
|
|
1131
|
+
'exact_session',
|
|
1132
|
+
`The active ${active.label} shell does not expose an exact session id.`,
|
|
1133
|
+
'use a blocking `notifai send --reply` question',
|
|
1134
|
+
)
|
|
841
1135
|
}
|
|
842
|
-
const routeProblems = activeQuestionRouteProblems(
|
|
1136
|
+
const routeProblems = activeQuestionRouteProblems(installationDeps, active, installations)
|
|
843
1137
|
if (routeProblems.length > 0) {
|
|
844
|
-
|
|
845
|
-
|
|
1138
|
+
return askFailure(
|
|
1139
|
+
deps,
|
|
1140
|
+
flags,
|
|
1141
|
+
'question_routing_unavailable',
|
|
1142
|
+
'hook_contract',
|
|
1143
|
+
`Question routing is not ready: ${routeProblems.join('; ')}`,
|
|
1144
|
+
'run `notifai init --json` and follow its question-routing remedy',
|
|
1145
|
+
)
|
|
846
1146
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1147
|
+
const state = exactState ?? readSessionState(active.sessionId, deps.env)
|
|
1148
|
+
if (state.harness !== active.harness || state.last_prompt_at === undefined) {
|
|
1149
|
+
return askFailure(
|
|
1150
|
+
deps,
|
|
1151
|
+
flags,
|
|
1152
|
+
'session_not_activated',
|
|
1153
|
+
'user_prompt_submit',
|
|
1154
|
+
`This exact ${active.label} session has not fired UserPromptSubmit.`,
|
|
1155
|
+
`send one prompt in this ${active.label} session, then retry \`notifai ask --json\``,
|
|
1156
|
+
)
|
|
1157
|
+
}
|
|
1158
|
+
if (state.last_stop_at === undefined) {
|
|
1159
|
+
return askFailure(
|
|
1160
|
+
deps,
|
|
1161
|
+
flags,
|
|
1162
|
+
'stop_not_observed',
|
|
1163
|
+
'stop_hook',
|
|
1164
|
+
`This exact ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed.`,
|
|
1165
|
+
`end one harmless turn, send a new prompt, then retry \`notifai ask --json\``,
|
|
850
1166
|
)
|
|
851
|
-
return EXIT.usage
|
|
852
1167
|
}
|
|
853
|
-
sessionId =
|
|
1168
|
+
sessionId = active.sessionId
|
|
854
1169
|
} else {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1170
|
+
return askFailure(
|
|
1171
|
+
deps,
|
|
1172
|
+
flags,
|
|
1173
|
+
'session_identity_missing',
|
|
1174
|
+
'exact_session',
|
|
1175
|
+
'Could not prove which live harness session owns this command.',
|
|
1176
|
+
'run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question',
|
|
858
1177
|
)
|
|
859
|
-
return EXIT.usage
|
|
860
1178
|
}
|
|
861
1179
|
if (!sessionId) {
|
|
862
|
-
|
|
863
|
-
|
|
1180
|
+
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active session is available.', 'use a blocking `notifai send --reply` question')
|
|
1181
|
+
}
|
|
1182
|
+
let routingConfig: CliConfig
|
|
1183
|
+
try {
|
|
1184
|
+
routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env, sessionId })
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
return askFailure(
|
|
1187
|
+
deps,
|
|
1188
|
+
flags,
|
|
1189
|
+
'config_invalid',
|
|
1190
|
+
'configuration',
|
|
1191
|
+
`Question routing configuration is invalid: ${String(err)}`,
|
|
1192
|
+
'fix the reported Notifai configuration and retry',
|
|
1193
|
+
)
|
|
1194
|
+
}
|
|
1195
|
+
if (!routingConfig.ask_notifications.value) {
|
|
1196
|
+
return askFailure(
|
|
1197
|
+
deps,
|
|
1198
|
+
flags,
|
|
1199
|
+
'question_routing_disabled',
|
|
1200
|
+
'ask_notifications',
|
|
1201
|
+
'Question routing is disabled by ask_notifications=false.',
|
|
1202
|
+
'enable question routing or use a blocking `notifai send --reply` question',
|
|
1203
|
+
)
|
|
864
1204
|
}
|
|
865
1205
|
const source = resolveDraftInvocation(deps, flags, active)
|
|
866
1206
|
if (!source.ok) {
|
|
867
|
-
deps
|
|
868
|
-
return EXIT.usage
|
|
1207
|
+
return askFailure(deps, flags, 'invalid_source', 'source_context', source.error, 'remove the conflicting source override and retry')
|
|
869
1208
|
}
|
|
870
1209
|
if (source.invocation.source?.session_id !== sessionId) {
|
|
871
|
-
|
|
872
|
-
|
|
1210
|
+
return askFailure(
|
|
1211
|
+
deps,
|
|
1212
|
+
flags,
|
|
1213
|
+
'session_identity_mismatch',
|
|
1214
|
+
'exact_session',
|
|
1215
|
+
`The inferred session does not match the exact active ${active.label} session.`,
|
|
1216
|
+
'remove NOTIFAI_SESSION_ID and retry from the exact active session',
|
|
873
1217
|
)
|
|
874
|
-
return EXIT.usage
|
|
875
1218
|
}
|
|
876
1219
|
|
|
877
1220
|
// Placeholders let every body, source, project, media, and payload limit fail
|
|
@@ -879,8 +1222,7 @@ export function askCommand(
|
|
|
879
1222
|
const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
|
|
880
1223
|
const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
|
|
881
1224
|
if (!preflight.ok) {
|
|
882
|
-
deps
|
|
883
|
-
return EXIT.usage
|
|
1225
|
+
return askFailure(deps, flags, 'invalid_draft', 'draft', preflight.error, 'fix the reported field and retry')
|
|
884
1226
|
}
|
|
885
1227
|
if ((flags.image?.length ?? 0) > 0) {
|
|
886
1228
|
return uploadAskMedia(
|
|
@@ -946,79 +1288,6 @@ export function activeQuestionRouteProblems(
|
|
|
946
1288
|
return [...new Set(problems)]
|
|
947
1289
|
}
|
|
948
1290
|
|
|
949
|
-
type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
|
|
950
|
-
|
|
951
|
-
function diagnoseActiveHarnessSession(
|
|
952
|
-
active: ActiveHarnessSession,
|
|
953
|
-
problem: ActiveHarnessProblem,
|
|
954
|
-
installations: Installation[],
|
|
955
|
-
contested: ActiveHarnessSession[] = [],
|
|
956
|
-
): string[] {
|
|
957
|
-
// Naming one harness confidently is wrong when the environment carries the
|
|
958
|
-
// markers of several and none of them has fired here: whichever one the
|
|
959
|
-
// agent is told to prompt may not be the one it is running in.
|
|
960
|
-
const ambiguity =
|
|
961
|
-
contested.length > 1
|
|
962
|
-
? [
|
|
963
|
-
`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.`,
|
|
964
|
-
]
|
|
965
|
-
: []
|
|
966
|
-
if (problem === 'not-installed') {
|
|
967
|
-
const others = installations.map((installation) => installation.harness)
|
|
968
|
-
return [
|
|
969
|
-
`Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
|
|
970
|
-
...ambiguity,
|
|
971
|
-
...(others.length === 0
|
|
972
|
-
? []
|
|
973
|
-
: [
|
|
974
|
-
`Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
|
|
975
|
-
]),
|
|
976
|
-
`Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
|
|
977
|
-
`Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
|
|
978
|
-
]
|
|
979
|
-
}
|
|
980
|
-
if (problem === 'different-session') {
|
|
981
|
-
return [
|
|
982
|
-
`Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
|
|
983
|
-
...ambiguity,
|
|
984
|
-
`Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
|
|
985
|
-
`Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
|
|
986
|
-
]
|
|
987
|
-
}
|
|
988
|
-
return [
|
|
989
|
-
`Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
|
|
990
|
-
...ambiguity,
|
|
991
|
-
`Send one ${active.label} prompt, then run \`notifai doctor\`.`,
|
|
992
|
-
`Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
|
|
993
|
-
]
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Why `ask` cannot see a session, in terms of what to do about it.
|
|
998
|
-
*
|
|
999
|
-
* Only a UserPromptSubmit hook firing produces the pointer this reads, and the
|
|
1000
|
-
* old message answered every cause with "run `notifai hooks install` and send
|
|
1001
|
-
* one prompt". The useful next action depends on the harness: some reload
|
|
1002
|
-
* project hook files, OpenCode loads its plugin at startup, and Codex should be
|
|
1003
|
-
* checked after a prompt before assuming that a new session is required.
|
|
1004
|
-
*/
|
|
1005
|
-
function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
1006
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
1007
|
-
if (installations.length === 0) {
|
|
1008
|
-
return [
|
|
1009
|
-
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
1010
|
-
'Run `notifai hooks install`, then follow the activation instruction it prints.',
|
|
1011
|
-
]
|
|
1012
|
-
}
|
|
1013
|
-
const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
|
|
1014
|
-
return [
|
|
1015
|
-
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1016
|
-
'but no usable session pointer from the last 24 hours exists here.',
|
|
1017
|
-
hookActivationAdvice(installations),
|
|
1018
|
-
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
1019
|
-
]
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
1291
|
/** The least disruptive verified way to make each installed adapter run once. */
|
|
1023
1292
|
export function hookActivationAdvice(installations: Installation[]): string {
|
|
1024
1293
|
const harnesses = new Set(installations.map((installation) => installation.harness))
|
|
@@ -1028,14 +1297,14 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1028
1297
|
(installation) => installation.harness === 'claude-code' && !installation.global,
|
|
1029
1298
|
)
|
|
1030
1299
|
) {
|
|
1031
|
-
advice.push('Claude Code:
|
|
1300
|
+
advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`')
|
|
1032
1301
|
}
|
|
1033
1302
|
if (
|
|
1034
1303
|
installations.some(
|
|
1035
1304
|
(installation) => installation.harness === 'claude-code' && installation.global,
|
|
1036
1305
|
)
|
|
1037
1306
|
) {
|
|
1038
|
-
advice.push('Claude Code global hooks:
|
|
1307
|
+
advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`')
|
|
1039
1308
|
}
|
|
1040
1309
|
if (
|
|
1041
1310
|
installations.some(
|
|
@@ -1043,7 +1312,7 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1043
1312
|
)
|
|
1044
1313
|
) {
|
|
1045
1314
|
advice.push(
|
|
1046
|
-
'Cursor: send one prompt, then run `notifai doctor
|
|
1315
|
+
'Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`',
|
|
1047
1316
|
)
|
|
1048
1317
|
}
|
|
1049
1318
|
if (
|
|
@@ -1051,11 +1320,11 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1051
1320
|
(installation) => installation.harness === 'cursor' && installation.global,
|
|
1052
1321
|
)
|
|
1053
1322
|
) {
|
|
1054
|
-
advice.push('Cursor global hooks: send one prompt
|
|
1323
|
+
advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`')
|
|
1055
1324
|
}
|
|
1056
1325
|
if (harnesses.has('codex')) {
|
|
1057
1326
|
advice.push(
|
|
1058
|
-
'Codex:
|
|
1327
|
+
'Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`',
|
|
1059
1328
|
)
|
|
1060
1329
|
}
|
|
1061
1330
|
if (harnesses.has('opencode')) {
|
|
@@ -1092,10 +1361,14 @@ export async function acknowledgeCommand(
|
|
|
1092
1361
|
}
|
|
1093
1362
|
|
|
1094
1363
|
const logger = log(deps)
|
|
1095
|
-
const
|
|
1364
|
+
const lifecycleSession = resolveCommandSession(deps, requestId)
|
|
1365
|
+
const config = loadLoggedConfig(deps, {
|
|
1366
|
+
cwd: deps.cwd,
|
|
1367
|
+
env: deps.env,
|
|
1368
|
+
...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
|
|
1369
|
+
})
|
|
1096
1370
|
logger.info('acknowledgement.attempted', {
|
|
1097
1371
|
request_id: requestId,
|
|
1098
|
-
text,
|
|
1099
1372
|
characters: text.length,
|
|
1100
1373
|
})
|
|
1101
1374
|
const authed = authedClient(deps, config)
|
|
@@ -1114,13 +1387,12 @@ export async function acknowledgeCommand(
|
|
|
1114
1387
|
logger.info('acknowledgement.outcome', {
|
|
1115
1388
|
request_id: requestId,
|
|
1116
1389
|
outcome: result.status,
|
|
1117
|
-
|
|
1390
|
+
text_chars: result.agent_acknowledgement.text.length,
|
|
1118
1391
|
created_at: result.agent_acknowledgement.created_at,
|
|
1119
1392
|
agent_acknowledgement_required: true,
|
|
1120
1393
|
})
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
clearAcknowledgementObligation(sessionId, deps.env, requestId)
|
|
1394
|
+
if (lifecycleSession !== null) {
|
|
1395
|
+
clearAcknowledgementObligation(lifecycleSession.sessionId, deps.env, requestId)
|
|
1124
1396
|
}
|
|
1125
1397
|
if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
|
|
1126
1398
|
else {
|
|
@@ -1137,14 +1409,36 @@ export async function acknowledgeCommand(
|
|
|
1137
1409
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1138
1410
|
export async function closeCommand(
|
|
1139
1411
|
deps: CommandDeps,
|
|
1140
|
-
requestId: string,
|
|
1141
|
-
flags: { json?: boolean } = {},
|
|
1412
|
+
requestId: string | undefined,
|
|
1413
|
+
flags: { json?: boolean; pending?: boolean } = {},
|
|
1142
1414
|
): Promise<number> {
|
|
1143
|
-
|
|
1415
|
+
if (flags.pending === true && requestId !== undefined) {
|
|
1416
|
+
deps.io.err('Pass a request id or --pending, not both.')
|
|
1417
|
+
return EXIT.usage
|
|
1418
|
+
}
|
|
1419
|
+
if (flags.pending !== true && requestId === undefined) {
|
|
1420
|
+
deps.io.err('Pass a request id or --pending.')
|
|
1421
|
+
return EXIT.usage
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (flags.pending === true) {
|
|
1425
|
+
return closePendingQuestions(deps, flags.json === true)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const local = await closeLocalQuestion(deps, requestId!, flags.json === true)
|
|
1429
|
+
if (local !== null) return local
|
|
1430
|
+
|
|
1431
|
+
const lifecycleSession = resolveCommandSession(deps, requestId!)
|
|
1432
|
+
const config = loadLoggedConfig(deps, {
|
|
1433
|
+
cwd: deps.cwd,
|
|
1434
|
+
env: deps.env,
|
|
1435
|
+
...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
|
|
1436
|
+
})
|
|
1144
1437
|
const authed = authedClient(deps, config)
|
|
1145
1438
|
if (!authed) return EXIT.auth
|
|
1146
1439
|
try {
|
|
1147
|
-
const response = await authed.client.closeReplies(requestId)
|
|
1440
|
+
const response = await authed.client.closeReplies(requestId!)
|
|
1441
|
+
forgetClosedQuestion(deps, requestId!)
|
|
1148
1442
|
if (flags.json) {
|
|
1149
1443
|
deps.io.out(
|
|
1150
1444
|
JSON.stringify(
|
|
@@ -1172,6 +1466,177 @@ export async function closeCommand(
|
|
|
1172
1466
|
}
|
|
1173
1467
|
}
|
|
1174
1468
|
|
|
1469
|
+
/**
|
|
1470
|
+
* Retire this session's outstanding questions, including registrations the
|
|
1471
|
+
* Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
|
|
1472
|
+
* them is the whole retirement. Live ones still need their reply window closed.
|
|
1473
|
+
*/
|
|
1474
|
+
async function closePendingQuestions(deps: CommandDeps, json: boolean): Promise<number> {
|
|
1475
|
+
const lifecycleSession = resolveCommandSession(deps)
|
|
1476
|
+
if (lifecycleSession === null) {
|
|
1477
|
+
deps.io.err('No active session pointer is available in this directory.')
|
|
1478
|
+
return EXIT.noReply
|
|
1479
|
+
}
|
|
1480
|
+
const sessionId = lifecycleSession.sessionId
|
|
1481
|
+
const pending = readSessionState(sessionId, deps.env).pending ?? []
|
|
1482
|
+
if (pending.length === 0) {
|
|
1483
|
+
if (json) {
|
|
1484
|
+
deps.io.out(
|
|
1485
|
+
JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2),
|
|
1486
|
+
)
|
|
1487
|
+
} else {
|
|
1488
|
+
deps.io.err(`Session ${sessionId} has no registered question pending.`)
|
|
1489
|
+
}
|
|
1490
|
+
return EXIT.noReply
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env)
|
|
1494
|
+
const live = (readSessionState(sessionId, deps.env).pending ?? []).filter(
|
|
1495
|
+
(entry): entry is PendingQuestion & { request_id: string } =>
|
|
1496
|
+
entry.request_id !== undefined,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
const closed: string[] = []
|
|
1500
|
+
if (live.length > 0) {
|
|
1501
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId })
|
|
1502
|
+
const authed = authedClient(deps, config)
|
|
1503
|
+
if (!authed) return EXIT.auth
|
|
1504
|
+
for (const entry of live) {
|
|
1505
|
+
try {
|
|
1506
|
+
await authed.client.closeReplies(entry.request_id)
|
|
1507
|
+
parkClosedQuestion(sessionId, deps.env, entry)
|
|
1508
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1509
|
+
closed.push(entry.request_id)
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id })
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const output = {
|
|
1517
|
+
session_id: sessionId,
|
|
1518
|
+
withdrawn: withdrawn.map((entry) => ({
|
|
1519
|
+
question: entry.question,
|
|
1520
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1521
|
+
})),
|
|
1522
|
+
closed,
|
|
1523
|
+
}
|
|
1524
|
+
if (json) deps.io.out(JSON.stringify(output, null, 2))
|
|
1525
|
+
else {
|
|
1526
|
+
if (withdrawn.length > 0) {
|
|
1527
|
+
deps.io.out(
|
|
1528
|
+
withdrawn.length === 1
|
|
1529
|
+
? 'Withdrew 1 unpushed question so a later Stop will not send it.'
|
|
1530
|
+
: `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`,
|
|
1531
|
+
)
|
|
1532
|
+
}
|
|
1533
|
+
for (const requestId of closed) {
|
|
1534
|
+
deps.io.out(`Closed the reply window for ${requestId}.`)
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return EXIT.ok
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Close one outstanding question by the stable id `notifai ask` returned, or
|
|
1542
|
+
* by a request id already on a device. Unpushed entries only exist locally.
|
|
1543
|
+
* Returns null when this directory's session has no matching registration, so
|
|
1544
|
+
* the caller can still close a live request id against the service.
|
|
1545
|
+
*/
|
|
1546
|
+
async function closeLocalQuestion(
|
|
1547
|
+
deps: CommandDeps,
|
|
1548
|
+
id: string,
|
|
1549
|
+
json: boolean,
|
|
1550
|
+
): Promise<number | null> {
|
|
1551
|
+
const lifecycleSession = resolveCommandSession(deps, id)
|
|
1552
|
+
if (lifecycleSession === null) return null
|
|
1553
|
+
const sessionId = lifecycleSession.sessionId
|
|
1554
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
|
|
1555
|
+
(candidate) => candidate.question_id === id || candidate.request_id === id,
|
|
1556
|
+
)
|
|
1557
|
+
if (entry === undefined) return null
|
|
1558
|
+
|
|
1559
|
+
if (entry.request_id === undefined) {
|
|
1560
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1561
|
+
const output = {
|
|
1562
|
+
session_id: sessionId,
|
|
1563
|
+
withdrawn: [
|
|
1564
|
+
{
|
|
1565
|
+
question: entry.question,
|
|
1566
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1567
|
+
},
|
|
1568
|
+
],
|
|
1569
|
+
closed: [] as string[],
|
|
1570
|
+
}
|
|
1571
|
+
if (json) deps.io.out(JSON.stringify(output, null, 2))
|
|
1572
|
+
else {
|
|
1573
|
+
deps.io.out(
|
|
1574
|
+
`Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`,
|
|
1575
|
+
)
|
|
1576
|
+
}
|
|
1577
|
+
return EXIT.ok
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId })
|
|
1581
|
+
const authed = authedClient(deps, config)
|
|
1582
|
+
if (!authed) return EXIT.auth
|
|
1583
|
+
try {
|
|
1584
|
+
const response = await authed.client.closeReplies(entry.request_id)
|
|
1585
|
+
parkClosedQuestion(sessionId, deps.env, entry)
|
|
1586
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1587
|
+
if (json) {
|
|
1588
|
+
deps.io.out(
|
|
1589
|
+
JSON.stringify(
|
|
1590
|
+
{
|
|
1591
|
+
...response,
|
|
1592
|
+
question_id: entry.question_id,
|
|
1593
|
+
acknowledgement_command: acknowledgementCommand(
|
|
1594
|
+
response.request_id,
|
|
1595
|
+
response.agent_acknowledgement_required,
|
|
1596
|
+
response.agent_acknowledgement_text_required,
|
|
1597
|
+
response.agent_acknowledgement,
|
|
1598
|
+
response.replies.length > 0,
|
|
1599
|
+
),
|
|
1600
|
+
},
|
|
1601
|
+
null,
|
|
1602
|
+
2,
|
|
1603
|
+
),
|
|
1604
|
+
)
|
|
1605
|
+
} else {
|
|
1606
|
+
deps.io.out(`Closed the reply window for ${entry.request_id}.`)
|
|
1607
|
+
if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
|
|
1608
|
+
}
|
|
1609
|
+
return EXIT.ok
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id })
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function forgetClosedQuestion(deps: CommandDeps, requestId: string): void {
|
|
1616
|
+
const lifecycleSession = resolveCommandSession(deps, requestId)
|
|
1617
|
+
if (lifecycleSession === null) return
|
|
1618
|
+
const sessionId = lifecycleSession.sessionId
|
|
1619
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
|
|
1620
|
+
(candidate) => candidate.request_id === requestId,
|
|
1621
|
+
)
|
|
1622
|
+
if (entry === undefined) return
|
|
1623
|
+
parkClosedQuestion(sessionId, deps.env, entry)
|
|
1624
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function parkClosedQuestion(
|
|
1628
|
+
sessionId: string,
|
|
1629
|
+
env: NodeJS.ProcessEnv,
|
|
1630
|
+
entry: PendingQuestion,
|
|
1631
|
+
): void {
|
|
1632
|
+
try {
|
|
1633
|
+
parkForRetirement(sessionId, env, entry, 'expired')
|
|
1634
|
+
} catch {
|
|
1635
|
+
// Incomplete identifiers cannot be retired on a device; dropping the
|
|
1636
|
+
// local record is still the right way to stop a later Stop from waiting.
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1175
1640
|
export interface HooksInstallFlags {
|
|
1176
1641
|
harness?: string
|
|
1177
1642
|
global?: boolean
|
|
@@ -1220,12 +1685,20 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
|
|
|
1220
1685
|
|
|
1221
1686
|
function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
|
|
1222
1687
|
const label = HARNESS_LABELS[harness]
|
|
1688
|
+
const activation =
|
|
1689
|
+
harness === 'codex'
|
|
1690
|
+
? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
|
|
1691
|
+
: harness === 'cursor'
|
|
1692
|
+
? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
|
|
1693
|
+
: harness === 'opencode'
|
|
1694
|
+
? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
|
|
1695
|
+
: `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`
|
|
1223
1696
|
if (deps.io.interactive === true && deps.io.note) {
|
|
1224
|
-
void deps.io.note(`${file}\
|
|
1697
|
+
void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`)
|
|
1225
1698
|
return
|
|
1226
1699
|
}
|
|
1227
1700
|
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
1228
|
-
deps.io.out(
|
|
1701
|
+
deps.io.out(activation)
|
|
1229
1702
|
}
|
|
1230
1703
|
|
|
1231
1704
|
export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
|
|
@@ -1261,7 +1734,26 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1261
1734
|
)
|
|
1262
1735
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
1263
1736
|
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
1264
|
-
const
|
|
1737
|
+
const globalInstallations = otherScope.filter((installation) => installation.global)
|
|
1738
|
+
const globalInstallation = globalInstallations[0]
|
|
1739
|
+
const installedEvents = new Set(
|
|
1740
|
+
globalInstallation?.handlers
|
|
1741
|
+
.map((handler) => handlerEvent(handler.command))
|
|
1742
|
+
.filter((event): event is HookEvent =>
|
|
1743
|
+
event !== null && (HOOK_EVENTS as readonly string[]).includes(event),
|
|
1744
|
+
) ?? [],
|
|
1745
|
+
)
|
|
1746
|
+
if (
|
|
1747
|
+
globalInstallations.length !== 1 ||
|
|
1748
|
+
globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
|
|
1749
|
+
globalInstallations.some(
|
|
1750
|
+
(installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0,
|
|
1751
|
+
) ||
|
|
1752
|
+
!requiredHookEvents(harness).every((event) => installedEvents.has(event))
|
|
1753
|
+
) {
|
|
1754
|
+
return hooksInstallCommand(deps, { ...flags, global: true, harness })
|
|
1755
|
+
}
|
|
1756
|
+
const globalFile = globalInstallation?.file
|
|
1265
1757
|
deps.io.out(
|
|
1266
1758
|
`${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}`,
|
|
1267
1759
|
)
|
|
@@ -1337,7 +1829,22 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1337
1829
|
installed =
|
|
1338
1830
|
codexPaths === null
|
|
1339
1831
|
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
1340
|
-
: withCodexLayerTransaction(codexPaths, (inspection) =>
|
|
1832
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1833
|
+
const staleTarget =
|
|
1834
|
+
inspection.writeTarget === inspection.paths.hooksJson
|
|
1835
|
+
? inspection.paths.configToml
|
|
1836
|
+
: inspection.paths.hooksJson
|
|
1837
|
+
const staleEvents =
|
|
1838
|
+
staleTarget === inspection.paths.hooksJson
|
|
1839
|
+
? inspection.ourJsonEvents
|
|
1840
|
+
: inspection.ourTomlEvents
|
|
1841
|
+
if (staleEvents.length > 0) {
|
|
1842
|
+
const staleDocument = loadSettings(staleTarget)
|
|
1843
|
+
const stripped = removeHooks(staleDocument, scriptPath)
|
|
1844
|
+
if (stripped.replaced.length > 0) applyPlan(staleTarget, stripped.document)
|
|
1845
|
+
}
|
|
1846
|
+
return installInto(inspection.writeTarget)
|
|
1847
|
+
})
|
|
1341
1848
|
} catch (err) {
|
|
1342
1849
|
deps.io.err(String(err))
|
|
1343
1850
|
return EXIT.failed
|
|
@@ -1373,7 +1880,12 @@ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { comm
|
|
|
1373
1880
|
if (!Array.isArray(groups)) return []
|
|
1374
1881
|
return groups
|
|
1375
1882
|
.flatMap((group) => group.hooks ?? [])
|
|
1376
|
-
.filter(
|
|
1883
|
+
.filter(
|
|
1884
|
+
(handler) =>
|
|
1885
|
+
!/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(
|
|
1886
|
+
handler.command,
|
|
1887
|
+
),
|
|
1888
|
+
)
|
|
1377
1889
|
}
|
|
1378
1890
|
|
|
1379
1891
|
/**
|
|
@@ -1503,7 +2015,11 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1503
2015
|
result =
|
|
1504
2016
|
codexPaths === null
|
|
1505
2017
|
? removeInstalledHooks()
|
|
1506
|
-
: withCodexLayerTransaction(codexPaths,
|
|
2018
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
2019
|
+
const stripped = removeInstalledHooks()
|
|
2020
|
+
cleanupEmptiedCodexLayer(inspection.paths)
|
|
2021
|
+
return stripped
|
|
2022
|
+
})
|
|
1507
2023
|
} catch (err) {
|
|
1508
2024
|
deps.io.err(String(err))
|
|
1509
2025
|
return EXIT.failed
|