@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.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -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-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. 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[]
@@ -460,7 +679,13 @@ interface AskFormQuestion {
460
679
 
461
680
  export interface BuiltQuestions {
462
681
  questions: QuestionT[]
463
- /** Canonical Markdown body: question block first, optional context second. */
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
- ? `${questionBlock}\n\n${context}`
534
- : questionBlock,
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
- text: built.questions[0]!.text,
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
- : '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.`,
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: 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`')
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: 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`')
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`; 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`',
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; 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`')
1053
1287
  }
1054
1288
  if (harnesses.has('codex')) {
1055
1289
  advice.push(
1056
- '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`',
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
- text: result.agent_acknowledgement.text,
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}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`)
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(`Send one ${label} prompt, then check \`notifai doctor\`.`)
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 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
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) => 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
+ })
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((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
+ )
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, removeInstalledHooks)
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