@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.
Files changed (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -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 = ['user-prompt-submit', 'stop', 'session-end'] as const
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, the decision (if any) goes to stdout, diagnostics go to stderr, and
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
- if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
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
- // A hook that returns stdout has taken over the turn; one that does not
341
- // has handed the terminal back. That distinction is the whole contract.
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
- text: built.questions[0]!.text,
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
- : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
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: send one new prompt; project hook files reload without a restart')
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: send one prompt; start a new session only if it does not fire')
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`; start a new session only if it still has not fired',
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; start a new session only if it does not fire')
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: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired',
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
- text: result.agent_acknowledgement.text,
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}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`)
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(`Send one ${label} prompt, then check \`notifai doctor\`.`)
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 globalFile = otherScope.find((installation) => installation.global)?.file
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) => installInto(inspection.writeTarget))
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((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command))
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, removeInstalledHooks)
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