@raidiant/notifai 7.0.2 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/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[]
|
|
@@ -460,7 +679,13 @@ interface AskFormQuestion {
|
|
|
460
679
|
|
|
461
680
|
export interface BuiltQuestions {
|
|
462
681
|
questions: QuestionT[]
|
|
463
|
-
/**
|
|
682
|
+
/**
|
|
683
|
+
* Canonical Markdown body. The question already travels as the notification
|
|
684
|
+
* title and as structured questions, so the body carries only the context —
|
|
685
|
+
* repeating the question there put it on the lock screen and the reply
|
|
686
|
+
* screen twice. Only when there is no context does the question text stand
|
|
687
|
+
* in, because the wire requires a body.
|
|
688
|
+
*/
|
|
464
689
|
body: string
|
|
465
690
|
}
|
|
466
691
|
|
|
@@ -524,14 +749,13 @@ export function buildQuestions(
|
|
|
524
749
|
questions.push(built.question)
|
|
525
750
|
}
|
|
526
751
|
const context = flags.body ?? (record['body'] as string | undefined)
|
|
527
|
-
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
|
|
528
752
|
return {
|
|
529
753
|
ok: true,
|
|
530
754
|
questions,
|
|
531
755
|
body:
|
|
532
756
|
context !== undefined && context.trim() !== ''
|
|
533
|
-
?
|
|
534
|
-
:
|
|
757
|
+
? context
|
|
758
|
+
: questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n'),
|
|
535
759
|
}
|
|
536
760
|
}
|
|
537
761
|
|
|
@@ -544,10 +768,7 @@ export function buildQuestions(
|
|
|
544
768
|
return {
|
|
545
769
|
ok: true,
|
|
546
770
|
questions: [built.question],
|
|
547
|
-
body:
|
|
548
|
-
context !== undefined && context.trim() !== ''
|
|
549
|
-
? `${built.question.text}\n\n${context}`
|
|
550
|
-
: built.question.text,
|
|
771
|
+
body: context !== undefined && context.trim() !== '' ? context : built.question.text,
|
|
551
772
|
}
|
|
552
773
|
}
|
|
553
774
|
|
|
@@ -625,8 +846,9 @@ function recordRegisteredQuestion(
|
|
|
625
846
|
draft: NotificationDraftT,
|
|
626
847
|
json = false,
|
|
627
848
|
): number {
|
|
849
|
+
let questionId: string
|
|
628
850
|
try {
|
|
629
|
-
registerQuestion(
|
|
851
|
+
questionId = registerQuestion(
|
|
630
852
|
sessionId,
|
|
631
853
|
deps.env,
|
|
632
854
|
{
|
|
@@ -647,8 +869,9 @@ function recordRegisteredQuestion(
|
|
|
647
869
|
log(deps).info('ask.registered', {
|
|
648
870
|
ok: true,
|
|
649
871
|
session: sessionId,
|
|
872
|
+
question_id: questionId,
|
|
650
873
|
questions: built.questions.length,
|
|
651
|
-
|
|
874
|
+
text_chars: built.questions[0]!.text.length,
|
|
652
875
|
choices: built.questions[0]!.choices?.length ?? 0,
|
|
653
876
|
media: draft.presentation.media?.length ?? 0,
|
|
654
877
|
})
|
|
@@ -661,12 +884,14 @@ function recordRegisteredQuestion(
|
|
|
661
884
|
JSON.stringify(
|
|
662
885
|
{
|
|
663
886
|
registered: true,
|
|
887
|
+
question_id: questionId,
|
|
664
888
|
questions: built.questions.map((entry) => ({
|
|
665
889
|
id: entry.id,
|
|
666
890
|
text: entry.text,
|
|
667
891
|
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
668
892
|
...(entry.multi === true ? { multi: true } : {}),
|
|
669
893
|
})),
|
|
894
|
+
close: `notifai close ${questionId}`,
|
|
670
895
|
next: {
|
|
671
896
|
end_turn: true,
|
|
672
897
|
in_this_turn:
|
|
@@ -675,6 +900,7 @@ function recordRegisteredQuestion(
|
|
|
675
900
|
'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
676
901
|
on_answer:
|
|
677
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.`,
|
|
678
904
|
},
|
|
679
905
|
},
|
|
680
906
|
null,
|
|
@@ -694,8 +920,8 @@ function recordRegisteredQuestion(
|
|
|
694
920
|
}
|
|
695
921
|
deps.io.out(
|
|
696
922
|
built.questions.length > 1
|
|
697
|
-
? `${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.`
|
|
698
|
-
:
|
|
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.`,
|
|
699
925
|
)
|
|
700
926
|
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
|
|
701
927
|
for (const [index, entry] of built.questions.entries()) {
|
|
@@ -721,6 +947,9 @@ function recordRegisteredQuestion(
|
|
|
721
947
|
deps.io.out(
|
|
722
948
|
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
723
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
|
+
)
|
|
724
953
|
return EXIT.ok
|
|
725
954
|
}
|
|
726
955
|
|
|
@@ -740,7 +969,7 @@ async function uploadAskMedia(
|
|
|
740
969
|
mediaIds.push(image)
|
|
741
970
|
continue
|
|
742
971
|
}
|
|
743
|
-
const uploaded = await uploadImage(deps, authed.client, image)
|
|
972
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
744
973
|
if (!uploaded.ok) {
|
|
745
974
|
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
746
975
|
return uploaded.exit
|
|
@@ -768,6 +997,11 @@ export function askCommand(
|
|
|
768
997
|
// Validate before route discovery. A malformed question belongs to the
|
|
769
998
|
// caller and should not be hidden behind whichever harness setup issue
|
|
770
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
|
+
}
|
|
771
1005
|
const built = buildQuestions(flags, question)
|
|
772
1006
|
if (!built.ok) {
|
|
773
1007
|
deps.io.err(built.error)
|
|
@@ -1026,14 +1260,14 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1026
1260
|
(installation) => installation.harness === 'claude-code' && !installation.global,
|
|
1027
1261
|
)
|
|
1028
1262
|
) {
|
|
1029
|
-
advice.push('Claude Code:
|
|
1263
|
+
advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`')
|
|
1030
1264
|
}
|
|
1031
1265
|
if (
|
|
1032
1266
|
installations.some(
|
|
1033
1267
|
(installation) => installation.harness === 'claude-code' && installation.global,
|
|
1034
1268
|
)
|
|
1035
1269
|
) {
|
|
1036
|
-
advice.push('Claude Code global hooks:
|
|
1270
|
+
advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`')
|
|
1037
1271
|
}
|
|
1038
1272
|
if (
|
|
1039
1273
|
installations.some(
|
|
@@ -1041,7 +1275,7 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1041
1275
|
)
|
|
1042
1276
|
) {
|
|
1043
1277
|
advice.push(
|
|
1044
|
-
'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`',
|
|
1045
1279
|
)
|
|
1046
1280
|
}
|
|
1047
1281
|
if (
|
|
@@ -1049,11 +1283,11 @@ export function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1049
1283
|
(installation) => installation.harness === 'cursor' && installation.global,
|
|
1050
1284
|
)
|
|
1051
1285
|
) {
|
|
1052
|
-
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`')
|
|
1053
1287
|
}
|
|
1054
1288
|
if (harnesses.has('codex')) {
|
|
1055
1289
|
advice.push(
|
|
1056
|
-
'Codex:
|
|
1290
|
+
'Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`',
|
|
1057
1291
|
)
|
|
1058
1292
|
}
|
|
1059
1293
|
if (harnesses.has('opencode')) {
|
|
@@ -1093,7 +1327,6 @@ export async function acknowledgeCommand(
|
|
|
1093
1327
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1094
1328
|
logger.info('acknowledgement.attempted', {
|
|
1095
1329
|
request_id: requestId,
|
|
1096
|
-
text,
|
|
1097
1330
|
characters: text.length,
|
|
1098
1331
|
})
|
|
1099
1332
|
const authed = authedClient(deps, config)
|
|
@@ -1112,7 +1345,7 @@ export async function acknowledgeCommand(
|
|
|
1112
1345
|
logger.info('acknowledgement.outcome', {
|
|
1113
1346
|
request_id: requestId,
|
|
1114
1347
|
outcome: result.status,
|
|
1115
|
-
|
|
1348
|
+
text_chars: result.agent_acknowledgement.text.length,
|
|
1116
1349
|
created_at: result.agent_acknowledgement.created_at,
|
|
1117
1350
|
agent_acknowledgement_required: true,
|
|
1118
1351
|
})
|
|
@@ -1135,14 +1368,31 @@ export async function acknowledgeCommand(
|
|
|
1135
1368
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1136
1369
|
export async function closeCommand(
|
|
1137
1370
|
deps: CommandDeps,
|
|
1138
|
-
requestId: string,
|
|
1139
|
-
flags: { json?: boolean } = {},
|
|
1371
|
+
requestId: string | undefined,
|
|
1372
|
+
flags: { json?: boolean; pending?: boolean } = {},
|
|
1140
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
|
+
|
|
1141
1390
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1142
1391
|
const authed = authedClient(deps, config)
|
|
1143
1392
|
if (!authed) return EXIT.auth
|
|
1144
1393
|
try {
|
|
1145
|
-
const response = await authed.client.closeReplies(requestId)
|
|
1394
|
+
const response = await authed.client.closeReplies(requestId!)
|
|
1395
|
+
forgetClosedQuestion(deps, requestId!)
|
|
1146
1396
|
if (flags.json) {
|
|
1147
1397
|
deps.io.out(
|
|
1148
1398
|
JSON.stringify(
|
|
@@ -1170,6 +1420,176 @@ export async function closeCommand(
|
|
|
1170
1420
|
}
|
|
1171
1421
|
}
|
|
1172
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
|
+
|
|
1173
1593
|
export interface HooksInstallFlags {
|
|
1174
1594
|
harness?: string
|
|
1175
1595
|
global?: boolean
|
|
@@ -1218,12 +1638,20 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
|
|
|
1218
1638
|
|
|
1219
1639
|
function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
|
|
1220
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\`.`
|
|
1221
1649
|
if (deps.io.interactive === true && deps.io.note) {
|
|
1222
|
-
void deps.io.note(`${file}\
|
|
1650
|
+
void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`)
|
|
1223
1651
|
return
|
|
1224
1652
|
}
|
|
1225
1653
|
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
1226
|
-
deps.io.out(
|
|
1654
|
+
deps.io.out(activation)
|
|
1227
1655
|
}
|
|
1228
1656
|
|
|
1229
1657
|
export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
|
|
@@ -1259,7 +1687,26 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1259
1687
|
)
|
|
1260
1688
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
1261
1689
|
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
1262
|
-
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
|
|
1263
1710
|
deps.io.out(
|
|
1264
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}`,
|
|
1265
1712
|
)
|
|
@@ -1335,7 +1782,22 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1335
1782
|
installed =
|
|
1336
1783
|
codexPaths === null
|
|
1337
1784
|
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
1338
|
-
: 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
|
+
})
|
|
1339
1801
|
} catch (err) {
|
|
1340
1802
|
deps.io.err(String(err))
|
|
1341
1803
|
return EXIT.failed
|
|
@@ -1371,7 +1833,12 @@ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { comm
|
|
|
1371
1833
|
if (!Array.isArray(groups)) return []
|
|
1372
1834
|
return groups
|
|
1373
1835
|
.flatMap((group) => group.hooks ?? [])
|
|
1374
|
-
.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
|
+
)
|
|
1375
1842
|
}
|
|
1376
1843
|
|
|
1377
1844
|
/**
|
|
@@ -1501,7 +1968,11 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1501
1968
|
result =
|
|
1502
1969
|
codexPaths === null
|
|
1503
1970
|
? removeInstalledHooks()
|
|
1504
|
-
: withCodexLayerTransaction(codexPaths,
|
|
1971
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => {
|
|
1972
|
+
const stripped = removeInstalledHooks()
|
|
1973
|
+
cleanupEmptiedCodexLayer(inspection.paths)
|
|
1974
|
+
return stripped
|
|
1975
|
+
})
|
|
1505
1976
|
} catch (err) {
|
|
1506
1977
|
deps.io.err(String(err))
|
|
1507
1978
|
return EXIT.failed
|