@raidiant/notifai 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +231 -85
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands-hooks.ts
CHANGED
|
@@ -30,19 +30,28 @@ 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
44
|
readMatchingProjectSessionPointer,
|
|
39
45
|
readProjectSession,
|
|
40
46
|
readSessionState,
|
|
47
|
+
resetCursorStopActivation,
|
|
41
48
|
registerQuestion,
|
|
42
49
|
waiterCeilingSeconds,
|
|
50
|
+
withdrawUnpushedQuestions,
|
|
43
51
|
type EscalationDeliveryRoute,
|
|
44
52
|
type HookContext,
|
|
45
53
|
type HookHarness,
|
|
54
|
+
type PendingQuestion,
|
|
46
55
|
} from './hooks.js'
|
|
47
56
|
import {
|
|
48
57
|
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
@@ -50,6 +59,7 @@ import {
|
|
|
50
59
|
applyPlan,
|
|
51
60
|
buildCursorHookConfig,
|
|
52
61
|
buildHookConfig,
|
|
62
|
+
cleanupEmptiedCodexLayer,
|
|
53
63
|
codexCoexistenceNotes,
|
|
54
64
|
codexHomeNote,
|
|
55
65
|
codexLayerDir,
|
|
@@ -76,6 +86,11 @@ import {
|
|
|
76
86
|
import { logConfigResolved, logSettingsFrom } from './logging.js'
|
|
77
87
|
import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
|
|
78
88
|
import { packageVersion } from './release.js'
|
|
89
|
+
import { rejectAccidentalEscapedNewlines } from './send.js'
|
|
90
|
+
import {
|
|
91
|
+
cursorStopActivationOutput,
|
|
92
|
+
sessionActivationOutput,
|
|
93
|
+
} from './session-activation.js'
|
|
79
94
|
import {
|
|
80
95
|
CHOICE_USAGE,
|
|
81
96
|
buildDraft,
|
|
@@ -87,6 +102,7 @@ import {
|
|
|
87
102
|
import {
|
|
88
103
|
EXIT,
|
|
89
104
|
authedClient,
|
|
105
|
+
diagnoseIgnoredOriginOverride,
|
|
90
106
|
loadLoggedConfig,
|
|
91
107
|
log,
|
|
92
108
|
makeClient,
|
|
@@ -112,9 +128,24 @@ import {
|
|
|
112
128
|
// hook / ask / close — harness integration
|
|
113
129
|
// ---------------------------------------------------------------------------
|
|
114
130
|
|
|
115
|
-
export const HOOK_EVENTS = [
|
|
131
|
+
export const HOOK_EVENTS = [
|
|
132
|
+
'session-start',
|
|
133
|
+
'subagent-start',
|
|
134
|
+
'activation-stop',
|
|
135
|
+
'user-prompt-submit',
|
|
136
|
+
'stop',
|
|
137
|
+
'session-end',
|
|
138
|
+
] as const
|
|
116
139
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
117
140
|
|
|
141
|
+
/** Lifecycle handlers one installed harness must carry in this CLI build. */
|
|
142
|
+
export function requiredHookEvents(harness: Harness): readonly HookEvent[] {
|
|
143
|
+
if (harness === 'opencode') return []
|
|
144
|
+
return harness === 'cursor'
|
|
145
|
+
? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
|
|
146
|
+
: ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end']
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
119
150
|
export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
120
151
|
event: unknown,
|
|
@@ -124,8 +155,8 @@ export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
|
124
155
|
|
|
125
156
|
/**
|
|
126
157
|
* 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".
|
|
158
|
+
* stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
|
|
159
|
+
* exit 0 with no stdout means "no decision or added context — carry on as normal".
|
|
129
160
|
*
|
|
130
161
|
* Every failure path in here must reach that no-decision state. A hook that
|
|
131
162
|
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
@@ -166,6 +197,21 @@ export async function hookRunCommand(
|
|
|
166
197
|
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
167
198
|
: { message: err instanceof Error ? err.message : String(err) }
|
|
168
199
|
|
|
200
|
+
// Cursor may load ~/.claude/settings.json in addition to its own native
|
|
201
|
+
// hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
|
|
202
|
+
// compatibility copy becomes a no-op and the native Cursor definition is
|
|
203
|
+
// the single owner. Real Claude hooks do not receive that hook-only marker.
|
|
204
|
+
if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
|
|
205
|
+
start({ outcome: 'cursor-compatibility-copy-skipped' })
|
|
206
|
+
logger.info('hook.end', {
|
|
207
|
+
hook: event,
|
|
208
|
+
outcome: 'ignored',
|
|
209
|
+
reason: 'cursor-native-handler-owns-event',
|
|
210
|
+
decided: false,
|
|
211
|
+
})
|
|
212
|
+
return EXIT.ok
|
|
213
|
+
}
|
|
214
|
+
|
|
169
215
|
let raw: string
|
|
170
216
|
try {
|
|
171
217
|
raw = await readStdin()
|
|
@@ -209,6 +255,172 @@ export async function hookRunCommand(
|
|
|
209
255
|
const cwd = envelope.cwd ?? deps.cwd
|
|
210
256
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
211
257
|
logger.bind({ session: envelope.session_id ?? null })
|
|
258
|
+
const declaredSourcePid = declaredHookSourcePid(deps)
|
|
259
|
+
const activationOwnerPid =
|
|
260
|
+
harness === 'claude-code'
|
|
261
|
+
? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
|
|
262
|
+
: harness === 'codex'
|
|
263
|
+
? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
|
|
264
|
+
: undefined
|
|
265
|
+
|
|
266
|
+
// Session activation is local model context, not authenticated routing. It
|
|
267
|
+
// must survive the exact first-run states it exists to repair: no config,
|
|
268
|
+
// no credential, no Companion device, and no network.
|
|
269
|
+
if (event === 'session-start' || event === 'subagent-start') {
|
|
270
|
+
start({ cwd, source: envelope.source ?? null })
|
|
271
|
+
if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
|
|
272
|
+
try {
|
|
273
|
+
resetCursorStopActivation(envelope.session_id, deps.env)
|
|
274
|
+
} catch (err) {
|
|
275
|
+
logger.error('hook.end', {
|
|
276
|
+
hook: event,
|
|
277
|
+
outcome: 'reset-failed',
|
|
278
|
+
...failureData(err),
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const stdout = sessionActivationOutput(
|
|
283
|
+
harness,
|
|
284
|
+
event === 'session-start' ? 'SessionStart' : 'SubagentStart',
|
|
285
|
+
)
|
|
286
|
+
if (stdout !== undefined) deps.io.out(stdout)
|
|
287
|
+
if (
|
|
288
|
+
stdout !== undefined &&
|
|
289
|
+
event === 'session-start' &&
|
|
290
|
+
activationOwnerPid !== undefined &&
|
|
291
|
+
envelope.session_id !== undefined
|
|
292
|
+
) {
|
|
293
|
+
try {
|
|
294
|
+
// Write stdout first. If the process dies between these operations a
|
|
295
|
+
// later prompt may duplicate context, but it cannot suppress context
|
|
296
|
+
// that the host never received.
|
|
297
|
+
markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
logger.error('hook.end', {
|
|
300
|
+
hook: event,
|
|
301
|
+
outcome: 'mark-failed',
|
|
302
|
+
reason: 'activation-state-failed',
|
|
303
|
+
...failureData(err),
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
logger.info('hook.end', {
|
|
308
|
+
hook: event,
|
|
309
|
+
outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
|
|
310
|
+
decided: false,
|
|
311
|
+
})
|
|
312
|
+
return EXIT.ok
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// SessionStart is the preferred model-visible activation seam. A few
|
|
316
|
+
// managed hosts regenerate older hook definitions that retain
|
|
317
|
+
// UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
|
|
318
|
+
// once per exact session as a compatibility fallback, before config/auth/
|
|
319
|
+
// device/network work, and stay silent when SessionStart already ran.
|
|
320
|
+
let promptActivationStdout: string | undefined
|
|
321
|
+
if (
|
|
322
|
+
event === 'user-prompt-submit' &&
|
|
323
|
+
(harness === 'claude-code' || harness === 'codex') &&
|
|
324
|
+
activationOwnerPid !== undefined &&
|
|
325
|
+
envelope.session_id !== undefined
|
|
326
|
+
) {
|
|
327
|
+
try {
|
|
328
|
+
if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid)) {
|
|
329
|
+
promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit')
|
|
330
|
+
if (promptActivationStdout !== undefined) deps.io.out(promptActivationStdout)
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
logger.error('hook.end', {
|
|
334
|
+
hook: event,
|
|
335
|
+
outcome: 'claim-failed',
|
|
336
|
+
reason: 'activation-state-failed',
|
|
337
|
+
...failureData(err),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Cursor has a confirmed host bug in which sessionStart.additional_context
|
|
343
|
+
// is accepted but never reaches the model. A native Stop follow-up is the
|
|
344
|
+
// host's guaranteed model-visible channel. Claim it once per conversation,
|
|
345
|
+
// before config/auth/network, and leave the ordinary Stop handler separate
|
|
346
|
+
// so question delivery is never displaced by activation.
|
|
347
|
+
if (event === 'activation-stop') {
|
|
348
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
349
|
+
if (harness !== 'cursor') {
|
|
350
|
+
logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false })
|
|
351
|
+
return EXIT.ok
|
|
352
|
+
}
|
|
353
|
+
if (envelope.status === 'aborted') {
|
|
354
|
+
logger.info('hook.end', {
|
|
355
|
+
hook: event,
|
|
356
|
+
outcome: 'ignored',
|
|
357
|
+
reason: `turn-${envelope.status}`,
|
|
358
|
+
decided: false,
|
|
359
|
+
})
|
|
360
|
+
return EXIT.ok
|
|
361
|
+
}
|
|
362
|
+
if (envelope.session_id === undefined) {
|
|
363
|
+
logger.info('hook.end', {
|
|
364
|
+
hook: event,
|
|
365
|
+
outcome: 'ignored',
|
|
366
|
+
reason: 'missing-conversation-id',
|
|
367
|
+
decided: false,
|
|
368
|
+
})
|
|
369
|
+
return EXIT.ok
|
|
370
|
+
}
|
|
371
|
+
if (
|
|
372
|
+
envelope.session_id !== undefined &&
|
|
373
|
+
typeof envelope.loop_count === 'number' &&
|
|
374
|
+
envelope.loop_count > 0
|
|
375
|
+
) {
|
|
376
|
+
let activationOwned = false
|
|
377
|
+
try {
|
|
378
|
+
activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now())
|
|
379
|
+
} catch (err) {
|
|
380
|
+
logger.error('hook.end', {
|
|
381
|
+
hook: event,
|
|
382
|
+
outcome: 'confirm-failed',
|
|
383
|
+
...failureData(err),
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
if (activationOwned) {
|
|
387
|
+
logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false })
|
|
388
|
+
return EXIT.ok
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const cursorState = readSessionState(envelope.session_id, deps.env)
|
|
392
|
+
if (
|
|
393
|
+
(cursorState.pending?.length ?? 0) > 0 ||
|
|
394
|
+
cursorState.accepted !== undefined ||
|
|
395
|
+
(cursorState.acknowledgement_due?.length ?? 0) > 0
|
|
396
|
+
) {
|
|
397
|
+
logger.info('hook.end', {
|
|
398
|
+
hook: event,
|
|
399
|
+
outcome: 'deferred',
|
|
400
|
+
reason: 'question-continuation-owns-stop',
|
|
401
|
+
decided: false,
|
|
402
|
+
})
|
|
403
|
+
return EXIT.ok
|
|
404
|
+
}
|
|
405
|
+
let claimed = false
|
|
406
|
+
try {
|
|
407
|
+
claimed = claimCursorStopActivation(envelope.session_id, deps.env, now())
|
|
408
|
+
} catch (err) {
|
|
409
|
+
logger.error('hook.end', {
|
|
410
|
+
hook: event,
|
|
411
|
+
outcome: 'claim-failed',
|
|
412
|
+
...failureData(err),
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
logger.info('hook.end', {
|
|
416
|
+
hook: event,
|
|
417
|
+
outcome: claimed ? 'followup-added' : 'already-activated',
|
|
418
|
+
decided: claimed,
|
|
419
|
+
})
|
|
420
|
+
if (claimed) deps.io.out(cursorStopActivationOutput())
|
|
421
|
+
return EXIT.ok
|
|
422
|
+
}
|
|
423
|
+
|
|
212
424
|
let config: CliConfig | null = null
|
|
213
425
|
let configFailure: unknown
|
|
214
426
|
try {
|
|
@@ -230,6 +442,7 @@ export async function hookRunCommand(
|
|
|
230
442
|
hook: event,
|
|
231
443
|
outcome: 'failed',
|
|
232
444
|
reason: 'config-failed',
|
|
445
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
233
446
|
...failureData(err),
|
|
234
447
|
})
|
|
235
448
|
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
@@ -275,11 +488,7 @@ export async function hookRunCommand(
|
|
|
275
488
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
276
489
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
277
490
|
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
|
-
}
|
|
491
|
+
diagnoseIgnoredOriginOverride(deps.io, resolved, credential)
|
|
283
492
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
284
493
|
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
285
494
|
// Stop is allowed to block and keeps the ordinary budget.
|
|
@@ -337,9 +546,10 @@ export async function hookRunCommand(
|
|
|
337
546
|
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
338
547
|
logger.info('hook.end', {
|
|
339
548
|
hook: event,
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
decided: outcome.stdout !== undefined,
|
|
549
|
+
// Stop stdout takes over the turn. UserPromptSubmit stdout may instead
|
|
550
|
+
// add context to the user's own turn, which is explicitly non-decisive.
|
|
551
|
+
decided: outcome.decided ?? outcome.stdout !== undefined,
|
|
552
|
+
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
343
553
|
...(notes.length === 0 ? {} : { notes }),
|
|
344
554
|
...outcome.log,
|
|
345
555
|
})
|
|
@@ -379,12 +589,13 @@ function stopWakeRoute(
|
|
|
379
589
|
cwd: string,
|
|
380
590
|
): EscalationDeliveryRoute | undefined {
|
|
381
591
|
if (sessionId === undefined) return undefined
|
|
592
|
+
const declaredSourcePid = declaredHookSourcePid(deps)
|
|
382
593
|
if (harness === 'claude-code') {
|
|
383
594
|
if ((deps.hookPlatform ?? process.platform) === 'win32') return undefined
|
|
384
595
|
return claudeWakeRoute({
|
|
385
596
|
sessionId,
|
|
386
597
|
cwd,
|
|
387
|
-
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
598
|
+
sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
|
|
388
599
|
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
389
600
|
})
|
|
390
601
|
}
|
|
@@ -392,7 +603,7 @@ function stopWakeRoute(
|
|
|
392
603
|
return codexWakeRoute({
|
|
393
604
|
threadId: sessionId,
|
|
394
605
|
cwd,
|
|
395
|
-
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
606
|
+
sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
|
|
396
607
|
env: deps.env,
|
|
397
608
|
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
398
609
|
})
|
|
@@ -400,6 +611,12 @@ function stopWakeRoute(
|
|
|
400
611
|
return undefined
|
|
401
612
|
}
|
|
402
613
|
|
|
614
|
+
/** Stable harness parent propagated by the managed adapter across child tools. */
|
|
615
|
+
function declaredHookSourcePid(deps: CommandDeps): number | undefined {
|
|
616
|
+
const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID'])
|
|
617
|
+
return Number.isInteger(value) && value > 0 ? value : undefined
|
|
618
|
+
}
|
|
619
|
+
|
|
403
620
|
|
|
404
621
|
/**
|
|
405
622
|
* What went wrong, in terms of what to do about it.
|
|
@@ -442,6 +659,8 @@ export interface AskFlags {
|
|
|
442
659
|
multi?: boolean
|
|
443
660
|
/** Optional Markdown context appended after the question block. */
|
|
444
661
|
body?: string
|
|
662
|
+
/** Allow visible backslash-n sequences in `--body`. */
|
|
663
|
+
literalBackslashN?: boolean
|
|
445
664
|
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
446
665
|
form?: string
|
|
447
666
|
image?: string[]
|
|
@@ -627,8 +846,9 @@ function recordRegisteredQuestion(
|
|
|
627
846
|
draft: NotificationDraftT,
|
|
628
847
|
json = false,
|
|
629
848
|
): number {
|
|
849
|
+
let questionId: string
|
|
630
850
|
try {
|
|
631
|
-
registerQuestion(
|
|
851
|
+
questionId = registerQuestion(
|
|
632
852
|
sessionId,
|
|
633
853
|
deps.env,
|
|
634
854
|
{
|
|
@@ -649,8 +869,9 @@ function recordRegisteredQuestion(
|
|
|
649
869
|
log(deps).info('ask.registered', {
|
|
650
870
|
ok: true,
|
|
651
871
|
session: sessionId,
|
|
872
|
+
question_id: questionId,
|
|
652
873
|
questions: built.questions.length,
|
|
653
|
-
|
|
874
|
+
text_chars: built.questions[0]!.text.length,
|
|
654
875
|
choices: built.questions[0]!.choices?.length ?? 0,
|
|
655
876
|
media: draft.presentation.media?.length ?? 0,
|
|
656
877
|
})
|
|
@@ -663,12 +884,14 @@ function recordRegisteredQuestion(
|
|
|
663
884
|
JSON.stringify(
|
|
664
885
|
{
|
|
665
886
|
registered: true,
|
|
887
|
+
question_id: questionId,
|
|
666
888
|
questions: built.questions.map((entry) => ({
|
|
667
889
|
id: entry.id,
|
|
668
890
|
text: entry.text,
|
|
669
891
|
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
670
892
|
...(entry.multi === true ? { multi: true } : {}),
|
|
671
893
|
})),
|
|
894
|
+
close: `notifai close ${questionId}`,
|
|
672
895
|
next: {
|
|
673
896
|
end_turn: true,
|
|
674
897
|
in_this_turn:
|
|
@@ -677,6 +900,7 @@ function recordRegisteredQuestion(
|
|
|
677
900
|
'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
678
901
|
on_answer:
|
|
679
902
|
'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
903
|
+
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
904
|
},
|
|
681
905
|
},
|
|
682
906
|
null,
|
|
@@ -696,8 +920,8 @@ function recordRegisteredQuestion(
|
|
|
696
920
|
}
|
|
697
921
|
deps.io.out(
|
|
698
922
|
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
|
-
:
|
|
923
|
+
? `${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.`
|
|
924
|
+
: `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`,
|
|
701
925
|
)
|
|
702
926
|
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
|
|
703
927
|
for (const [index, entry] of built.questions.entries()) {
|
|
@@ -723,6 +947,9 @@ function recordRegisteredQuestion(
|
|
|
723
947
|
deps.io.out(
|
|
724
948
|
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
725
949
|
)
|
|
950
|
+
deps.io.out(
|
|
951
|
+
`If they answer in this conversation instead, retire it with \`notifai close ${questionId}\` so a later Stop will not send it.`,
|
|
952
|
+
)
|
|
726
953
|
return EXIT.ok
|
|
727
954
|
}
|
|
728
955
|
|
|
@@ -742,7 +969,7 @@ async function uploadAskMedia(
|
|
|
742
969
|
mediaIds.push(image)
|
|
743
970
|
continue
|
|
744
971
|
}
|
|
745
|
-
const uploaded = await uploadImage(deps, authed.client, image)
|
|
972
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
746
973
|
if (!uploaded.ok) {
|
|
747
974
|
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
748
975
|
return uploaded.exit
|
|
@@ -770,6 +997,11 @@ export function askCommand(
|
|
|
770
997
|
// Validate before route discovery. A malformed question belongs to the
|
|
771
998
|
// caller and should not be hidden behind whichever harness setup issue
|
|
772
999
|
// happens to exist on this machine.
|
|
1000
|
+
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
|
|
1001
|
+
if (escapedBody !== null) {
|
|
1002
|
+
deps.io.err(escapedBody)
|
|
1003
|
+
return EXIT.usage
|
|
1004
|
+
}
|
|
773
1005
|
const built = buildQuestions(flags, question)
|
|
774
1006
|
if (!built.ok) {
|
|
775
1007
|
deps.io.err(built.error)
|
|
@@ -1028,14 +1260,14 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1028
1260
|
(installation) => installation.harness === 'claude-code' && !installation.global,
|
|
1029
1261
|
)
|
|
1030
1262
|
) {
|
|
1031
|
-
advice.push('Claude Code:
|
|
1263
|
+
advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`')
|
|
1032
1264
|
}
|
|
1033
1265
|
if (
|
|
1034
1266
|
installations.some(
|
|
1035
1267
|
(installation) => installation.harness === 'claude-code' && installation.global,
|
|
1036
1268
|
)
|
|
1037
1269
|
) {
|
|
1038
|
-
advice.push('Claude Code global hooks:
|
|
1270
|
+
advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`')
|
|
1039
1271
|
}
|
|
1040
1272
|
if (
|
|
1041
1273
|
installations.some(
|
|
@@ -1043,7 +1275,7 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1043
1275
|
)
|
|
1044
1276
|
) {
|
|
1045
1277
|
advice.push(
|
|
1046
|
-
'Cursor: send one prompt, then run `notifai doctor
|
|
1278
|
+
'Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`',
|
|
1047
1279
|
)
|
|
1048
1280
|
}
|
|
1049
1281
|
if (
|
|
@@ -1051,11 +1283,11 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1051
1283
|
(installation) => installation.harness === 'cursor' && installation.global,
|
|
1052
1284
|
)
|
|
1053
1285
|
) {
|
|
1054
|
-
advice.push('Cursor global hooks: send one prompt
|
|
1286
|
+
advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`')
|
|
1055
1287
|
}
|
|
1056
1288
|
if (harnesses.has('codex')) {
|
|
1057
1289
|
advice.push(
|
|
1058
|
-
'Codex:
|
|
1290
|
+
'Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`',
|
|
1059
1291
|
)
|
|
1060
1292
|
}
|
|
1061
1293
|
if (harnesses.has('opencode')) {
|
|
@@ -1095,7 +1327,6 @@ export async function acknowledgeCommand(
|
|
|
1095
1327
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1096
1328
|
logger.info('acknowledgement.attempted', {
|
|
1097
1329
|
request_id: requestId,
|
|
1098
|
-
text,
|
|
1099
1330
|
characters: text.length,
|
|
1100
1331
|
})
|
|
1101
1332
|
const authed = authedClient(deps, config)
|
|
@@ -1114,7 +1345,7 @@ export async function acknowledgeCommand(
|
|
|
1114
1345
|
logger.info('acknowledgement.outcome', {
|
|
1115
1346
|
request_id: requestId,
|
|
1116
1347
|
outcome: result.status,
|
|
1117
|
-
|
|
1348
|
+
text_chars: result.agent_acknowledgement.text.length,
|
|
1118
1349
|
created_at: result.agent_acknowledgement.created_at,
|
|
1119
1350
|
agent_acknowledgement_required: true,
|
|
1120
1351
|
})
|
|
@@ -1137,14 +1368,31 @@ export async function acknowledgeCommand(
|
|
|
1137
1368
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1138
1369
|
export async function closeCommand(
|
|
1139
1370
|
deps: CommandDeps,
|
|
1140
|
-
requestId: string,
|
|
1141
|
-
flags: { json?: boolean } = {},
|
|
1371
|
+
requestId: string | undefined,
|
|
1372
|
+
flags: { json?: boolean; pending?: boolean } = {},
|
|
1142
1373
|
): Promise<number> {
|
|
1374
|
+
if (flags.pending === true && requestId !== undefined) {
|
|
1375
|
+
deps.io.err('Pass a request id or --pending, not both.')
|
|
1376
|
+
return EXIT.usage
|
|
1377
|
+
}
|
|
1378
|
+
if (flags.pending !== true && requestId === undefined) {
|
|
1379
|
+
deps.io.err('Pass a request id or --pending.')
|
|
1380
|
+
return EXIT.usage
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (flags.pending === true) {
|
|
1384
|
+
return closePendingQuestions(deps, flags.json === true)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const local = await closeLocalQuestion(deps, requestId!, flags.json === true)
|
|
1388
|
+
if (local !== null) return local
|
|
1389
|
+
|
|
1143
1390
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1144
1391
|
const authed = authedClient(deps, config)
|
|
1145
1392
|
if (!authed) return EXIT.auth
|
|
1146
1393
|
try {
|
|
1147
|
-
const response = await authed.client.closeReplies(requestId)
|
|
1394
|
+
const response = await authed.client.closeReplies(requestId!)
|
|
1395
|
+
forgetClosedQuestion(deps, requestId!)
|
|
1148
1396
|
if (flags.json) {
|
|
1149
1397
|
deps.io.out(
|
|
1150
1398
|
JSON.stringify(
|
|
@@ -1172,6 +1420,176 @@ export async function closeCommand(
|
|
|
1172
1420
|
}
|
|
1173
1421
|
}
|
|
1174
1422
|
|
|
1423
|
+
/**
|
|
1424
|
+
* Retire this session's outstanding questions, including registrations the
|
|
1425
|
+
* Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
|
|
1426
|
+
* them is the whole retirement. Live ones still need their reply window closed.
|
|
1427
|
+
*/
|
|
1428
|
+
async function closePendingQuestions(deps: CommandDeps, json: boolean): Promise<number> {
|
|
1429
|
+
const now = (deps.now ?? Date.now)()
|
|
1430
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, now)
|
|
1431
|
+
if (sessionId === null) {
|
|
1432
|
+
deps.io.err('No active session pointer is available in this directory.')
|
|
1433
|
+
return EXIT.noReply
|
|
1434
|
+
}
|
|
1435
|
+
const pending = readSessionState(sessionId, deps.env).pending ?? []
|
|
1436
|
+
if (pending.length === 0) {
|
|
1437
|
+
if (json) {
|
|
1438
|
+
deps.io.out(
|
|
1439
|
+
JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2),
|
|
1440
|
+
)
|
|
1441
|
+
} else {
|
|
1442
|
+
deps.io.err(`Session ${sessionId} has no registered question pending.`)
|
|
1443
|
+
}
|
|
1444
|
+
return EXIT.noReply
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env)
|
|
1448
|
+
const live = (readSessionState(sessionId, deps.env).pending ?? []).filter(
|
|
1449
|
+
(entry): entry is PendingQuestion & { request_id: string } =>
|
|
1450
|
+
entry.request_id !== undefined,
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
const closed: string[] = []
|
|
1454
|
+
if (live.length > 0) {
|
|
1455
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1456
|
+
const authed = authedClient(deps, config)
|
|
1457
|
+
if (!authed) return EXIT.auth
|
|
1458
|
+
for (const entry of live) {
|
|
1459
|
+
try {
|
|
1460
|
+
await authed.client.closeReplies(entry.request_id)
|
|
1461
|
+
parkClosedQuestion(sessionId, deps.env, entry)
|
|
1462
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1463
|
+
closed.push(entry.request_id)
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id })
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const output = {
|
|
1471
|
+
session_id: sessionId,
|
|
1472
|
+
withdrawn: withdrawn.map((entry) => ({
|
|
1473
|
+
question: entry.question,
|
|
1474
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1475
|
+
})),
|
|
1476
|
+
closed,
|
|
1477
|
+
}
|
|
1478
|
+
if (json) deps.io.out(JSON.stringify(output, null, 2))
|
|
1479
|
+
else {
|
|
1480
|
+
if (withdrawn.length > 0) {
|
|
1481
|
+
deps.io.out(
|
|
1482
|
+
withdrawn.length === 1
|
|
1483
|
+
? 'Withdrew 1 unpushed question so a later Stop will not send it.'
|
|
1484
|
+
: `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`,
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1487
|
+
for (const requestId of closed) {
|
|
1488
|
+
deps.io.out(`Closed the reply window for ${requestId}.`)
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return EXIT.ok
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Close one outstanding question by the stable id `notifai ask` returned, or
|
|
1496
|
+
* by a request id already on a device. Unpushed entries only exist locally.
|
|
1497
|
+
* Returns null when this directory's session has no matching registration, so
|
|
1498
|
+
* the caller can still close a live request id against the service.
|
|
1499
|
+
*/
|
|
1500
|
+
async function closeLocalQuestion(
|
|
1501
|
+
deps: CommandDeps,
|
|
1502
|
+
id: string,
|
|
1503
|
+
json: boolean,
|
|
1504
|
+
): Promise<number | null> {
|
|
1505
|
+
const now = (deps.now ?? Date.now)()
|
|
1506
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, now)
|
|
1507
|
+
if (sessionId === null) return null
|
|
1508
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
|
|
1509
|
+
(candidate) => candidate.question_id === id || candidate.request_id === id,
|
|
1510
|
+
)
|
|
1511
|
+
if (entry === undefined) return null
|
|
1512
|
+
|
|
1513
|
+
if (entry.request_id === undefined) {
|
|
1514
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1515
|
+
const output = {
|
|
1516
|
+
session_id: sessionId,
|
|
1517
|
+
withdrawn: [
|
|
1518
|
+
{
|
|
1519
|
+
question: entry.question,
|
|
1520
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
1521
|
+
},
|
|
1522
|
+
],
|
|
1523
|
+
closed: [] as string[],
|
|
1524
|
+
}
|
|
1525
|
+
if (json) deps.io.out(JSON.stringify(output, null, 2))
|
|
1526
|
+
else {
|
|
1527
|
+
deps.io.out(
|
|
1528
|
+
`Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`,
|
|
1529
|
+
)
|
|
1530
|
+
}
|
|
1531
|
+
return EXIT.ok
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1535
|
+
const authed = authedClient(deps, config)
|
|
1536
|
+
if (!authed) return EXIT.auth
|
|
1537
|
+
try {
|
|
1538
|
+
const response = await authed.client.closeReplies(entry.request_id)
|
|
1539
|
+
parkClosedQuestion(sessionId, deps.env, entry)
|
|
1540
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1541
|
+
if (json) {
|
|
1542
|
+
deps.io.out(
|
|
1543
|
+
JSON.stringify(
|
|
1544
|
+
{
|
|
1545
|
+
...response,
|
|
1546
|
+
question_id: entry.question_id,
|
|
1547
|
+
acknowledgement_command: acknowledgementCommand(
|
|
1548
|
+
response.request_id,
|
|
1549
|
+
response.agent_acknowledgement_required,
|
|
1550
|
+
response.agent_acknowledgement_text_required,
|
|
1551
|
+
response.agent_acknowledgement,
|
|
1552
|
+
response.replies.length > 0,
|
|
1553
|
+
),
|
|
1554
|
+
},
|
|
1555
|
+
null,
|
|
1556
|
+
2,
|
|
1557
|
+
),
|
|
1558
|
+
)
|
|
1559
|
+
} else {
|
|
1560
|
+
deps.io.out(`Closed the reply window for ${entry.request_id}.`)
|
|
1561
|
+
if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
|
|
1562
|
+
}
|
|
1563
|
+
return EXIT.ok
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
return reportError(deps, err, { operation: 'close', request_id: entry.request_id })
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function forgetClosedQuestion(deps: CommandDeps, requestId: string): void {
|
|
1570
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
1571
|
+
if (sessionId === null) return
|
|
1572
|
+
const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
|
|
1573
|
+
(candidate) => candidate.request_id === requestId,
|
|
1574
|
+
)
|
|
1575
|
+
if (entry === undefined) return
|
|
1576
|
+
parkClosedQuestion(sessionId, deps.env, entry)
|
|
1577
|
+
dropPendingQuestion(sessionId, deps.env, entry)
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function parkClosedQuestion(
|
|
1581
|
+
sessionId: string,
|
|
1582
|
+
env: NodeJS.ProcessEnv,
|
|
1583
|
+
entry: PendingQuestion,
|
|
1584
|
+
): void {
|
|
1585
|
+
try {
|
|
1586
|
+
parkForRetirement(sessionId, env, entry, 'expired')
|
|
1587
|
+
} catch {
|
|
1588
|
+
// Incomplete identifiers cannot be retired on a device; dropping the
|
|
1589
|
+
// local record is still the right way to stop a later Stop from waiting.
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1175
1593
|
export interface HooksInstallFlags {
|
|
1176
1594
|
harness?: string
|
|
1177
1595
|
global?: boolean
|
|
@@ -1220,12 +1638,20 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
|
|
|
1220
1638
|
|
|
1221
1639
|
function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
|
|
1222
1640
|
const label = HARNESS_LABELS[harness]
|
|
1641
|
+
const activation =
|
|
1642
|
+
harness === 'codex'
|
|
1643
|
+
? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
|
|
1644
|
+
: harness === 'cursor'
|
|
1645
|
+
? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
|
|
1646
|
+
: harness === 'opencode'
|
|
1647
|
+
? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
|
|
1648
|
+
: `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`
|
|
1223
1649
|
if (deps.io.interactive === true && deps.io.note) {
|
|
1224
|
-
void deps.io.note(`${file}\
|
|
1650
|
+
void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`)
|
|
1225
1651
|
return
|
|
1226
1652
|
}
|
|
1227
1653
|
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
1228
|
-
deps.io.out(
|
|
1654
|
+
deps.io.out(activation)
|
|
1229
1655
|
}
|
|
1230
1656
|
|
|
1231
1657
|
export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
|
|
@@ -1261,7 +1687,26 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1261
1687
|
)
|
|
1262
1688
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
1263
1689
|
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
1264
|
-
const
|
|
1690
|
+
const globalInstallations = otherScope.filter((installation) => installation.global)
|
|
1691
|
+
const globalInstallation = globalInstallations[0]
|
|
1692
|
+
const installedEvents = new Set(
|
|
1693
|
+
globalInstallation?.handlers
|
|
1694
|
+
.map((handler) => handlerEvent(handler.command))
|
|
1695
|
+
.filter((event): event is HookEvent =>
|
|
1696
|
+
event !== null && (HOOK_EVENTS as readonly string[]).includes(event),
|
|
1697
|
+
) ?? [],
|
|
1698
|
+
)
|
|
1699
|
+
if (
|
|
1700
|
+
globalInstallations.length !== 1 ||
|
|
1701
|
+
globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
|
|
1702
|
+
globalInstallations.some(
|
|
1703
|
+
(installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0,
|
|
1704
|
+
) ||
|
|
1705
|
+
!requiredHookEvents(harness).every((event) => installedEvents.has(event))
|
|
1706
|
+
) {
|
|
1707
|
+
return hooksInstallCommand(deps, { ...flags, global: true, harness })
|
|
1708
|
+
}
|
|
1709
|
+
const globalFile = globalInstallation?.file
|
|
1265
1710
|
deps.io.out(
|
|
1266
1711
|
`${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
1712
|
)
|
|
@@ -1337,7 +1782,22 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1337
1782
|
installed =
|
|
1338
1783
|
codexPaths === null
|
|
1339
1784
|
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
1340
|
-
: withCodexLayerTransaction(codexPaths, (inspection) =>
|
|
1785
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1786
|
+
const staleTarget =
|
|
1787
|
+
inspection.writeTarget === inspection.paths.hooksJson
|
|
1788
|
+
? inspection.paths.configToml
|
|
1789
|
+
: inspection.paths.hooksJson
|
|
1790
|
+
const staleEvents =
|
|
1791
|
+
staleTarget === inspection.paths.hooksJson
|
|
1792
|
+
? inspection.ourJsonEvents
|
|
1793
|
+
: inspection.ourTomlEvents
|
|
1794
|
+
if (staleEvents.length > 0) {
|
|
1795
|
+
const staleDocument = loadSettings(staleTarget)
|
|
1796
|
+
const stripped = removeHooks(staleDocument, scriptPath)
|
|
1797
|
+
if (stripped.replaced.length > 0) applyPlan(staleTarget, stripped.document)
|
|
1798
|
+
}
|
|
1799
|
+
return installInto(inspection.writeTarget)
|
|
1800
|
+
})
|
|
1341
1801
|
} catch (err) {
|
|
1342
1802
|
deps.io.err(String(err))
|
|
1343
1803
|
return EXIT.failed
|
|
@@ -1373,7 +1833,12 @@ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { comm
|
|
|
1373
1833
|
if (!Array.isArray(groups)) return []
|
|
1374
1834
|
return groups
|
|
1375
1835
|
.flatMap((group) => group.hooks ?? [])
|
|
1376
|
-
.filter(
|
|
1836
|
+
.filter(
|
|
1837
|
+
(handler) =>
|
|
1838
|
+
!/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(
|
|
1839
|
+
handler.command,
|
|
1840
|
+
),
|
|
1841
|
+
)
|
|
1377
1842
|
}
|
|
1378
1843
|
|
|
1379
1844
|
/**
|
|
@@ -1503,7 +1968,11 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1503
1968
|
result =
|
|
1504
1969
|
codexPaths === null
|
|
1505
1970
|
? removeInstalledHooks()
|
|
1506
|
-
: withCodexLayerTransaction(codexPaths,
|
|
1971
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1972
|
+
const stripped = removeInstalledHooks()
|
|
1973
|
+
cleanupEmptiedCodexLayer(inspection.paths)
|
|
1974
|
+
return stripped
|
|
1975
|
+
})
|
|
1507
1976
|
} catch (err) {
|
|
1508
1977
|
deps.io.err(String(err))
|
|
1509
1978
|
return EXIT.failed
|