@raidiant/notifai 8.0.0 → 9.1.0

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