@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
@@ -9,28 +9,46 @@ import { loadConfig } from './config.js';
9
9
  import { withTargetFileLock } from './file-lock.js';
10
10
  import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
11
11
  import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
12
- import { clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readSessionState, registerQuestion, waiterCeilingSeconds, } from './hooks.js';
13
- import { BLOCKING_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexCoexistenceNotes, codexHomeNote, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, stopHandlerIsDetached, withCodexLayerTransaction, } from './install-hooks.js';
12
+ import { clearAcknowledgementObligation, claimPromptActivation, claimCursorStopActivation, confirmCursorStopActivation, dropPendingQuestion, handleSessionEnd, handleStop, handleUserPromptSubmit, markSessionActivation, parkForRetirement, parseHookInput, pruneAbandonedSessions, readSessionState, resetCursorStopActivation, registerQuestion, waiterCeilingSeconds, withdrawUnpushedQuestions, } from './hooks.js';
13
+ import { BLOCKING_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, cleanupEmptiedCodexLayer, codexCoexistenceNotes, codexHomeNote, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, stopHandlerIsDetached, withCodexLayerTransaction, } from './install-hooks.js';
14
14
  import { logConfigResolved, logSettingsFrom } from './logging.js';
15
15
  import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js';
16
16
  import { packageVersion } from './release.js';
17
+ import { rejectAccidentalEscapedNewlines } from './send.js';
18
+ import { cursorStopActivationOutput, sessionActivationOutput, } from './session-activation.js';
17
19
  import { CHOICE_USAGE, buildDraft, parseChoices, slugify, validateMediaInputs, } from './send.js';
18
- import { EXIT, authedClient, loadLoggedConfig, log, makeClient, rejectedPaths, reportError, } from './commands-core.js';
20
+ import { EXIT, authedClient, diagnoseIgnoredOriginOverride, loadLoggedConfig, log, makeClient, rejectedPaths, reportError, } from './commands-core.js';
19
21
  import { claudeSessionPid, resolveActiveHarness, } from './commands-harness-context.js';
20
22
  import { stopShapeProblems } from './commands-hook-shape.js';
21
23
  import { acknowledgementCommand, printAcknowledgementStatus, resolveDraftInvocation, uploadImage, waitForReply, } from './commands-send-support.js';
24
+ import { resolveCommandSession } from './command-session.js';
22
25
  // ---------------------------------------------------------------------------
23
26
  // hook / ask / close — harness integration
24
27
  // ---------------------------------------------------------------------------
25
- export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
28
+ export const HOOK_EVENTS = [
29
+ 'session-start',
30
+ 'subagent-start',
31
+ 'activation-stop',
32
+ 'user-prompt-submit',
33
+ 'stop',
34
+ 'session-end',
35
+ ];
36
+ /** Lifecycle handlers one installed harness must carry in this CLI build. */
37
+ export function requiredHookEvents(harness) {
38
+ if (harness === 'opencode')
39
+ return [];
40
+ return harness === 'cursor'
41
+ ? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
42
+ : ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end'];
43
+ }
26
44
  /** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
27
45
  export function hookDefersDiagnosticsUntilAfterCleanup(event) {
28
46
  return event === 'session-end';
29
47
  }
30
48
  /**
31
49
  * Runs one harness hook. Contract with every harness: hook JSON arrives on
32
- * stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
33
- * exit 0 with no stdout means "no decision — carry on as normal".
50
+ * stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
51
+ * exit 0 with no stdout means "no decision or added context — carry on as normal".
34
52
  *
35
53
  * Every failure path in here must reach that no-decision state. A hook that
36
54
  * throws, or that blocks past the harness's timeout, degrades the agent for a
@@ -63,6 +81,20 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
63
81
  const failureData = (err) => err instanceof ApiCallError
64
82
  ? { status: err.status, code: err.code, message: err.message, details: err.details }
65
83
  : { message: err instanceof Error ? err.message : String(err) };
84
+ // Cursor may load ~/.claude/settings.json in addition to its own native
85
+ // hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
86
+ // compatibility copy becomes a no-op and the native Cursor definition is
87
+ // the single owner. Real Claude hooks do not receive that hook-only marker.
88
+ if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
89
+ start({ outcome: 'cursor-compatibility-copy-skipped' });
90
+ logger.info('hook.end', {
91
+ hook: event,
92
+ outcome: 'ignored',
93
+ reason: 'cursor-native-handler-owns-event',
94
+ decided: false,
95
+ });
96
+ return EXIT.ok;
97
+ }
66
98
  let raw;
67
99
  try {
68
100
  raw = await readStdin();
@@ -105,6 +137,164 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
105
137
  const cwd = envelope.cwd ?? deps.cwd;
106
138
  const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
107
139
  logger.bind({ session: envelope.session_id ?? null });
140
+ const declaredSourcePid = declaredHookSourcePid(deps);
141
+ const activationOwnerPid = harness === 'claude-code'
142
+ ? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
143
+ : harness === 'codex'
144
+ ? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
145
+ : undefined;
146
+ // Session activation is local model context, not authenticated routing. It
147
+ // must survive the exact first-run states it exists to repair: no config,
148
+ // no credential, no Companion device, and no network.
149
+ if (event === 'session-start' || event === 'subagent-start') {
150
+ start({ cwd, source: envelope.source ?? null });
151
+ if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
152
+ try {
153
+ resetCursorStopActivation(envelope.session_id, deps.env);
154
+ }
155
+ catch (err) {
156
+ logger.error('hook.end', {
157
+ hook: event,
158
+ outcome: 'reset-failed',
159
+ ...failureData(err),
160
+ });
161
+ }
162
+ }
163
+ const stdout = sessionActivationOutput(harness, event === 'session-start' ? 'SessionStart' : 'SubagentStart', cwd, deps.env);
164
+ if (stdout !== undefined)
165
+ deps.io.out(stdout);
166
+ if (stdout !== undefined &&
167
+ event === 'session-start' &&
168
+ activationOwnerPid !== undefined &&
169
+ envelope.session_id !== undefined) {
170
+ try {
171
+ // Write stdout first. If the process dies between these operations a
172
+ // later prompt may duplicate context, but it cannot suppress context
173
+ // that the host never received.
174
+ markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd);
175
+ }
176
+ catch (err) {
177
+ logger.error('hook.end', {
178
+ hook: event,
179
+ outcome: 'mark-failed',
180
+ reason: 'activation-state-failed',
181
+ ...failureData(err),
182
+ });
183
+ }
184
+ }
185
+ logger.info('hook.end', {
186
+ hook: event,
187
+ outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
188
+ decided: false,
189
+ });
190
+ return EXIT.ok;
191
+ }
192
+ // SessionStart is the preferred model-visible activation seam. A few
193
+ // managed hosts regenerate older hook definitions that retain
194
+ // UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
195
+ // once per exact session as a compatibility fallback, before config/auth/
196
+ // device/network work, and stay silent when SessionStart already ran.
197
+ let promptActivationStdout;
198
+ if (event === 'user-prompt-submit' &&
199
+ (harness === 'claude-code' || harness === 'codex') &&
200
+ activationOwnerPid !== undefined &&
201
+ envelope.session_id !== undefined) {
202
+ try {
203
+ if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)) {
204
+ promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit', cwd, deps.env);
205
+ if (promptActivationStdout !== undefined)
206
+ deps.io.out(promptActivationStdout);
207
+ }
208
+ }
209
+ catch (err) {
210
+ logger.error('hook.end', {
211
+ hook: event,
212
+ outcome: 'claim-failed',
213
+ reason: 'activation-state-failed',
214
+ ...failureData(err),
215
+ });
216
+ }
217
+ }
218
+ // Cursor has a confirmed host bug in which sessionStart.additional_context
219
+ // is accepted but never reaches the model. A native Stop follow-up is the
220
+ // host's guaranteed model-visible channel. Claim it once per conversation,
221
+ // before config/auth/network, and leave the ordinary Stop handler separate
222
+ // so question delivery is never displaced by activation.
223
+ if (event === 'activation-stop') {
224
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
225
+ if (harness !== 'cursor') {
226
+ logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false });
227
+ return EXIT.ok;
228
+ }
229
+ if (envelope.status === 'aborted') {
230
+ logger.info('hook.end', {
231
+ hook: event,
232
+ outcome: 'ignored',
233
+ reason: `turn-${envelope.status}`,
234
+ decided: false,
235
+ });
236
+ return EXIT.ok;
237
+ }
238
+ if (envelope.session_id === undefined) {
239
+ logger.info('hook.end', {
240
+ hook: event,
241
+ outcome: 'ignored',
242
+ reason: 'missing-conversation-id',
243
+ decided: false,
244
+ });
245
+ return EXIT.ok;
246
+ }
247
+ if (envelope.session_id !== undefined &&
248
+ typeof envelope.loop_count === 'number' &&
249
+ envelope.loop_count > 0) {
250
+ let activationOwned = false;
251
+ try {
252
+ activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now());
253
+ }
254
+ catch (err) {
255
+ logger.error('hook.end', {
256
+ hook: event,
257
+ outcome: 'confirm-failed',
258
+ ...failureData(err),
259
+ });
260
+ }
261
+ if (activationOwned) {
262
+ logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false });
263
+ return EXIT.ok;
264
+ }
265
+ }
266
+ const cursorState = readSessionState(envelope.session_id, deps.env);
267
+ if ((cursorState.pending?.length ?? 0) > 0 ||
268
+ cursorState.accepted !== undefined ||
269
+ (cursorState.acknowledgement_due?.length ?? 0) > 0) {
270
+ logger.info('hook.end', {
271
+ hook: event,
272
+ outcome: 'deferred',
273
+ reason: 'question-continuation-owns-stop',
274
+ decided: false,
275
+ });
276
+ return EXIT.ok;
277
+ }
278
+ let claimed = false;
279
+ try {
280
+ claimed = claimCursorStopActivation(envelope.session_id, deps.env, now());
281
+ }
282
+ catch (err) {
283
+ logger.error('hook.end', {
284
+ hook: event,
285
+ outcome: 'claim-failed',
286
+ ...failureData(err),
287
+ });
288
+ }
289
+ logger.info('hook.end', {
290
+ hook: event,
291
+ outcome: claimed ? 'followup-added' : 'already-activated',
292
+ decided: claimed,
293
+ });
294
+ if (claimed)
295
+ deps.io.out(cursorStopActivationOutput(cwd, deps.env));
296
+ return EXIT.ok;
297
+ }
108
298
  let config = null;
109
299
  let configFailure;
110
300
  try {
@@ -127,6 +317,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
127
317
  hook: event,
128
318
  outcome: 'failed',
129
319
  reason: 'config-failed',
320
+ ...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
130
321
  ...failureData(err),
131
322
  });
132
323
  for (const line of describeHookFailure(err))
@@ -174,9 +365,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
174
365
  // repository can commit `.notifai/config.toml`, and honouring a base_url
175
366
  // from it would hand this machine's bearer token to whatever host it names.
176
367
  const baseUrl = credential.baseUrl;
177
- if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
178
- deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
179
- }
368
+ diagnoseIgnoredOriginOverride(deps.io, resolved, credential);
180
369
  // UserPromptSubmit runs in front of the user's own prompt under a 15s
181
370
  // harness ceiling and can make two calls, so each gets a small slice of it;
182
371
  // Stop is allowed to block and keeps the ordinary budget.
@@ -222,9 +411,10 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
222
411
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
223
412
  logger.info('hook.end', {
224
413
  hook: event,
225
- // A hook that returns stdout has taken over the turn; one that does not
226
- // has handed the terminal back. That distinction is the whole contract.
227
- decided: outcome.stdout !== undefined,
414
+ // Stop stdout takes over the turn. UserPromptSubmit stdout may instead
415
+ // add context to the user's own turn, which is explicitly non-decisive.
416
+ decided: outcome.decided ?? outcome.stdout !== undefined,
417
+ ...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
228
418
  ...(notes.length === 0 ? {} : { notes }),
229
419
  ...outcome.log,
230
420
  });
@@ -264,13 +454,14 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
264
454
  function stopWakeRoute(deps, harness, sessionId, cwd) {
265
455
  if (sessionId === undefined)
266
456
  return undefined;
457
+ const declaredSourcePid = declaredHookSourcePid(deps);
267
458
  if (harness === 'claude-code') {
268
459
  if ((deps.hookPlatform ?? process.platform) === 'win32')
269
460
  return undefined;
270
461
  return claudeWakeRoute({
271
462
  sessionId,
272
463
  cwd,
273
- sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
464
+ sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
274
465
  ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
275
466
  });
276
467
  }
@@ -278,13 +469,18 @@ function stopWakeRoute(deps, harness, sessionId, cwd) {
278
469
  return codexWakeRoute({
279
470
  threadId: sessionId,
280
471
  cwd,
281
- sourcePid: deps.codexSourcePid ?? process.ppid,
472
+ sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
282
473
  env: deps.env,
283
474
  ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
284
475
  });
285
476
  }
286
477
  return undefined;
287
478
  }
479
+ /** Stable harness parent propagated by the managed adapter across child tools. */
480
+ function declaredHookSourcePid(deps) {
481
+ const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID']);
482
+ return Number.isInteger(value) && value > 0 ? value : undefined;
483
+ }
288
484
  /**
289
485
  * What went wrong, in terms of what to do about it.
290
486
  *
@@ -315,6 +511,26 @@ export function describeHookFailure(err) {
315
511
  }
316
512
  return lines;
317
513
  }
514
+ /** One stable failure contract for every machine-readable ask refusal. */
515
+ export function reportAskFailure(deps, flags, failure) {
516
+ if (flags.json === true) {
517
+ deps.io.out(JSON.stringify({ ok: false, registered: false, ...failure }, null, 2));
518
+ }
519
+ else {
520
+ deps.io.err(failure.message);
521
+ deps.io.err(`next: ${failure.remedy}`);
522
+ }
523
+ return failure.exit_code;
524
+ }
525
+ function askFailure(deps, flags, code, checkId, message, remedy, exitCode = EXIT.usage) {
526
+ return reportAskFailure(deps, flags, {
527
+ code,
528
+ check_id: checkId,
529
+ exit_code: exitCode,
530
+ remedy,
531
+ message,
532
+ });
533
+ }
318
534
  /**
319
535
  * Turn ask input into questions plus their canonical body. Everything is
320
536
  * validated at registration because a later hook failure is easy to miss.
@@ -449,8 +665,9 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
449
665
  return { ok: true, draft: result.draft };
450
666
  }
451
667
  function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
668
+ let questionId;
452
669
  try {
453
- registerQuestion(sessionId, deps.env, {
670
+ questionId = registerQuestion(sessionId, deps.env, {
454
671
  question: built.questions[0].text,
455
672
  questions: built.questions,
456
673
  body: draft.presentation.body,
@@ -461,14 +678,14 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
461
678
  }
462
679
  catch (err) {
463
680
  log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
464
- deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
465
- return EXIT.failed;
681
+ return askFailure(deps, { json }, 'registration_failed', 'question_registration', `Could not register the question: ${err instanceof Error ? err.message : String(err)}`, 'retire an older pending question if the limit was reached, then retry the same ask', EXIT.failed);
466
682
  }
467
683
  log(deps).info('ask.registered', {
468
684
  ok: true,
469
685
  session: sessionId,
686
+ question_id: questionId,
470
687
  questions: built.questions.length,
471
- text: built.questions[0].text,
688
+ text_chars: built.questions[0].text.length,
472
689
  choices: built.questions[0].choices?.length ?? 0,
473
690
  media: draft.presentation.media?.length ?? 0,
474
691
  });
@@ -479,17 +696,20 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
479
696
  if (json) {
480
697
  deps.io.out(JSON.stringify({
481
698
  registered: true,
699
+ question_id: questionId,
482
700
  questions: built.questions.map((entry) => ({
483
701
  id: entry.id,
484
702
  text: entry.text,
485
703
  ...(entry.choices === undefined ? {} : { choices: entry.choices }),
486
704
  ...(entry.multi === true ? { multi: true } : {}),
487
705
  })),
706
+ close: `notifai close ${questionId}`,
488
707
  next: {
489
708
  end_turn: true,
490
709
  in_this_turn: 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
491
710
  route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
492
711
  on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
712
+ 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.`,
493
713
  },
494
714
  }, null, 2));
495
715
  return EXIT.ok;
@@ -505,8 +725,8 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
505
725
  }
506
726
  }
507
727
  deps.io.out(built.questions.length > 1
508
- ? `${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.`
509
- : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
728
+ ? `${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.`
729
+ : `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`);
510
730
  deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
511
731
  for (const [index, entry] of built.questions.entries()) {
512
732
  const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
@@ -522,30 +742,31 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
522
742
  }
523
743
  deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
524
744
  deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
745
+ deps.io.out(`If they answer in this conversation instead, retire it with \`notifai close ${questionId}\` so a later Stop will not send it.`);
525
746
  return EXIT.ok;
526
747
  }
527
748
  async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
528
749
  const authed = authedClient(deps, config);
529
- if (!authed)
530
- return EXIT.auth;
750
+ if (!authed) {
751
+ return askFailure(deps, flags, 'auth_required', 'credential', 'Question routing is not paired on this machine.', 'run `notifai init --json`', EXIT.auth);
752
+ }
531
753
  const mediaIds = [];
532
754
  for (const image of flags.image ?? []) {
533
755
  if (image.startsWith('med_')) {
534
756
  mediaIds.push(image);
535
757
  continue;
536
758
  }
537
- const uploaded = await uploadImage(deps, authed.client, image);
759
+ const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value);
538
760
  if (!uploaded.ok) {
539
761
  if (uploaded.error !== null)
540
762
  deps.io.err(uploaded.error);
541
- return uploaded.exit;
763
+ return askFailure(deps, flags, 'media_upload_failed', 'media', uploaded.error ?? 'The image upload failed.', 'fix the reported image or network problem, then retry the same ask', uploaded.exit);
542
764
  }
543
765
  mediaIds.push(uploaded.mediaId);
544
766
  }
545
767
  const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
546
768
  if (!ready.ok) {
547
- deps.io.err(ready.error);
548
- return EXIT.usage;
769
+ return askFailure(deps, flags, 'invalid_draft', 'draft', ready.error, 'fix the reported field and retry');
549
770
  }
550
771
  return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true);
551
772
  }
@@ -558,24 +779,20 @@ export function askCommand(deps, question, flags) {
558
779
  // Validate before route discovery. A malformed question belongs to the
559
780
  // caller and should not be hidden behind whichever harness setup issue
560
781
  // happens to exist on this machine.
782
+ const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN);
783
+ if (escapedBody !== null) {
784
+ return askFailure(deps, flags, 'invalid_input', 'body', escapedBody, 'fix the body and retry');
785
+ }
561
786
  const built = buildQuestions(flags, question);
562
787
  if (!built.ok) {
563
- deps.io.err(built.error);
564
- return EXIT.usage;
788
+ return askFailure(deps, flags, 'invalid_input', 'questions', built.error, 'fix the question input and retry');
565
789
  }
566
790
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
567
791
  if (mediaInputError !== null) {
568
- deps.io.err(mediaInputError);
569
- return EXIT.usage;
570
- }
571
- const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
572
- if (!routingConfig.ask_notifications.value) {
573
- deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
574
- return EXIT.usage;
792
+ return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry');
575
793
  }
576
794
  if (deps.store.load() === null) {
577
- deps.io.err('Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.');
578
- return EXIT.usage;
795
+ return askFailure(deps, flags, 'auth_required', 'credential', 'Question routing is not paired on this machine.', 'run `notifai init --json`', EXIT.auth);
579
796
  }
580
797
  // An agent calling this gets no hook payload. Harness-native environment
581
798
  // markers identify the active owner, while UserPromptSubmit adds the hook's
@@ -584,66 +801,64 @@ export function askCommand(deps, question, flags) {
584
801
  const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now);
585
802
  let sessionId;
586
803
  if (active !== null) {
587
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
804
+ if (contested.length > 1) {
805
+ return askFailure(deps, flags, 'session_identity_ambiguous', 'exact_session', `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}).`, 'run the ask from a shell with one exact active harness session');
806
+ }
807
+ const exactState = active.sessionId === undefined
808
+ ? null
809
+ : readSessionState(active.sessionId, deps.env);
810
+ const installationCwd = exactState?.activation_cwd ?? deps.cwd;
811
+ const installationDeps = installationCwd === deps.cwd ? deps : { ...deps, cwd: installationCwd };
812
+ const installations = findInstallations(installationCwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
588
813
  const activeInstalled = installations.some((installation) => installation.harness === active.harness);
589
814
  if (!activeInstalled) {
590
- for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations, contested)) {
591
- deps.io.err(line);
592
- }
593
- return EXIT.usage;
815
+ return askFailure(deps, flags, 'hooks_not_installed', 'hook_installation', `Notifai ${active.label} hooks are not installed for this project.`, `run \`notifai init --json\` from this ${active.label} session`);
594
816
  }
595
817
  if (active.sessionId === undefined) {
596
- for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
597
- deps.io.err(`Question routing is not ready: ${problem}`);
598
- }
599
- return EXIT.usage;
818
+ return askFailure(deps, flags, 'session_identity_missing', 'exact_session', `The active ${active.label} shell does not expose an exact session id.`, 'use a blocking `notifai send --reply` question');
600
819
  }
601
- const projectPointer = readMatchingProjectSessionPointer(deps.cwd, deps.env, now, active.sessionId, active.harness);
602
- if (projectPointer === null) {
603
- for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations, contested)) {
604
- deps.io.err(line);
605
- }
606
- return EXIT.usage;
607
- }
608
- const routeProblems = activeQuestionRouteProblems(deps, active, installations);
820
+ const routeProblems = activeQuestionRouteProblems(installationDeps, active, installations);
609
821
  if (routeProblems.length > 0) {
610
- for (const problem of routeProblems)
611
- deps.io.err(`Question routing is not ready: ${problem}`);
612
- return EXIT.usage;
822
+ return askFailure(deps, flags, 'question_routing_unavailable', 'hook_contract', `Question routing is not ready: ${routeProblems.join('; ')}`, 'run `notifai init --json` and follow its question-routing remedy');
613
823
  }
614
- if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
615
- deps.io.err(`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\`.`);
616
- return EXIT.usage;
824
+ const state = exactState ?? readSessionState(active.sessionId, deps.env);
825
+ if (state.harness !== active.harness || state.last_prompt_at === undefined) {
826
+ return askFailure(deps, flags, 'session_not_activated', 'user_prompt_submit', `This exact ${active.label} session has not fired UserPromptSubmit.`, `send one prompt in this ${active.label} session, then retry \`notifai ask --json\``);
617
827
  }
618
- sessionId = projectPointer.sessionId;
828
+ if (state.last_stop_at === undefined) {
829
+ return askFailure(deps, flags, 'stop_not_observed', 'stop_hook', `This exact ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed.`, `end one harmless turn, send a new prompt, then retry \`notifai ask --json\``);
830
+ }
831
+ sessionId = active.sessionId;
619
832
  }
620
833
  else {
621
- for (const line of diagnoseMissingSession(deps))
622
- deps.io.err(line);
623
- deps.io.err('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.');
624
- return EXIT.usage;
834
+ return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'Could not prove which live harness session owns this command.', 'run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question');
625
835
  }
626
836
  if (!sessionId) {
627
- for (const line of diagnoseMissingSession(deps))
628
- deps.io.err(line);
629
- return EXIT.usage;
837
+ return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active session is available.', 'use a blocking `notifai send --reply` question');
838
+ }
839
+ let routingConfig;
840
+ try {
841
+ routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env, sessionId });
842
+ }
843
+ catch (err) {
844
+ return askFailure(deps, flags, 'config_invalid', 'configuration', `Question routing configuration is invalid: ${String(err)}`, 'fix the reported Notifai configuration and retry');
845
+ }
846
+ if (!routingConfig.ask_notifications.value) {
847
+ return askFailure(deps, flags, 'question_routing_disabled', 'ask_notifications', 'Question routing is disabled by ask_notifications=false.', 'enable question routing or use a blocking `notifai send --reply` question');
630
848
  }
631
849
  const source = resolveDraftInvocation(deps, flags, active);
632
850
  if (!source.ok) {
633
- deps.io.err(source.error);
634
- return EXIT.usage;
851
+ return askFailure(deps, flags, 'invalid_source', 'source_context', source.error, 'remove the conflicting source override and retry');
635
852
  }
636
853
  if (source.invocation.source?.session_id !== sessionId) {
637
- deps.io.err(`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`);
638
- return EXIT.usage;
854
+ return askFailure(deps, flags, 'session_identity_mismatch', 'exact_session', `The inferred session does not match the exact active ${active.label} session.`, 'remove NOTIFAI_SESSION_ID and retry from the exact active session');
639
855
  }
640
856
  // Placeholders let every body, source, project, media, and payload limit fail
641
857
  // before an upload starts. The real ids replace them only after this passes.
642
858
  const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
643
859
  const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
644
860
  if (!preflight.ok) {
645
- deps.io.err(preflight.error);
646
- return EXIT.usage;
861
+ return askFailure(deps, flags, 'invalid_draft', 'draft', preflight.error, 'fix the reported field and retry');
647
862
  }
648
863
  if ((flags.image?.length ?? 0) > 0) {
649
864
  return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
@@ -688,87 +903,24 @@ export function activeQuestionRouteProblems(deps, active, installations) {
688
903
  problems.push(...codexTrustProblems(matching, deps.env));
689
904
  return [...new Set(problems)];
690
905
  }
691
- function diagnoseActiveHarnessSession(active, problem, installations, contested = []) {
692
- // Naming one harness confidently is wrong when the environment carries the
693
- // markers of several and none of them has fired here: whichever one the
694
- // agent is told to prompt may not be the one it is running in.
695
- const ambiguity = contested.length > 1
696
- ? [
697
- `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.`,
698
- ]
699
- : [];
700
- if (problem === 'not-installed') {
701
- const others = installations.map((installation) => installation.harness);
702
- return [
703
- `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
704
- ...ambiguity,
705
- ...(others.length === 0
706
- ? []
707
- : [
708
- `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
709
- ]),
710
- `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
711
- `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
712
- ];
713
- }
714
- if (problem === 'different-session') {
715
- return [
716
- `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
717
- ...ambiguity,
718
- `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
719
- `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
720
- ];
721
- }
722
- return [
723
- `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
724
- ...ambiguity,
725
- `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
726
- `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
727
- ];
728
- }
729
- /**
730
- * Why `ask` cannot see a session, in terms of what to do about it.
731
- *
732
- * Only a UserPromptSubmit hook firing produces the pointer this reads, and the
733
- * old message answered every cause with "run `notifai hooks install` and send
734
- * one prompt". The useful next action depends on the harness: some reload
735
- * project hook files, OpenCode loads its plugin at startup, and Codex should be
736
- * checked after a prompt before assuming that a new session is required.
737
- */
738
- function diagnoseMissingSession(deps) {
739
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
740
- if (installations.length === 0) {
741
- return [
742
- 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
743
- 'Run `notifai hooks install`, then follow the activation instruction it prints.',
744
- ];
745
- }
746
- const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
747
- return [
748
- `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
749
- 'but no usable session pointer from the last 24 hours exists here.',
750
- hookActivationAdvice(installations),
751
- 'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
752
- ];
753
- }
754
906
  /** The least disruptive verified way to make each installed adapter run once. */
755
907
  export function hookActivationAdvice(installations) {
756
908
  const harnesses = new Set(installations.map((installation) => installation.harness));
757
909
  const advice = [];
758
910
  if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
759
- advice.push('Claude Code: send one new prompt; project hook files reload without a restart');
911
+ advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`');
760
912
  }
761
913
  if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
762
- advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire');
914
+ advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`');
763
915
  }
764
916
  if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
765
- advice.push('Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
917
+ advice.push('Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
766
918
  }
767
919
  if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
768
- advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire');
920
+ advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
769
921
  }
770
922
  if (harnesses.has('codex')) {
771
- advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
923
+ advice.push('Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`');
772
924
  }
773
925
  if (harnesses.has('opencode')) {
774
926
  advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
@@ -794,10 +946,14 @@ export async function acknowledgeCommand(deps, requestId, flags) {
794
946
  return EXIT.usage;
795
947
  }
796
948
  const logger = log(deps);
797
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
949
+ const lifecycleSession = resolveCommandSession(deps, requestId);
950
+ const config = loadLoggedConfig(deps, {
951
+ cwd: deps.cwd,
952
+ env: deps.env,
953
+ ...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
954
+ });
798
955
  logger.info('acknowledgement.attempted', {
799
956
  request_id: requestId,
800
- text,
801
957
  characters: text.length,
802
958
  });
803
959
  const authed = authedClient(deps, config);
@@ -814,13 +970,12 @@ export async function acknowledgeCommand(deps, requestId, flags) {
814
970
  logger.info('acknowledgement.outcome', {
815
971
  request_id: requestId,
816
972
  outcome: result.status,
817
- text: result.agent_acknowledgement.text,
973
+ text_chars: result.agent_acknowledgement.text.length,
818
974
  created_at: result.agent_acknowledgement.created_at,
819
975
  agent_acknowledgement_required: true,
820
976
  });
821
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
822
- if (sessionId !== null) {
823
- clearAcknowledgementObligation(sessionId, deps.env, requestId);
977
+ if (lifecycleSession !== null) {
978
+ clearAcknowledgementObligation(lifecycleSession.sessionId, deps.env, requestId);
824
979
  }
825
980
  if (flags.json)
826
981
  deps.io.out(JSON.stringify(output, null, 2));
@@ -835,12 +990,32 @@ export async function acknowledgeCommand(deps, requestId, flags) {
835
990
  }
836
991
  /** Retire a question so a late answer is rejected rather than silently lost. */
837
992
  export async function closeCommand(deps, requestId, flags = {}) {
838
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
993
+ if (flags.pending === true && requestId !== undefined) {
994
+ deps.io.err('Pass a request id or --pending, not both.');
995
+ return EXIT.usage;
996
+ }
997
+ if (flags.pending !== true && requestId === undefined) {
998
+ deps.io.err('Pass a request id or --pending.');
999
+ return EXIT.usage;
1000
+ }
1001
+ if (flags.pending === true) {
1002
+ return closePendingQuestions(deps, flags.json === true);
1003
+ }
1004
+ const local = await closeLocalQuestion(deps, requestId, flags.json === true);
1005
+ if (local !== null)
1006
+ return local;
1007
+ const lifecycleSession = resolveCommandSession(deps, requestId);
1008
+ const config = loadLoggedConfig(deps, {
1009
+ cwd: deps.cwd,
1010
+ env: deps.env,
1011
+ ...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
1012
+ });
839
1013
  const authed = authedClient(deps, config);
840
1014
  if (!authed)
841
1015
  return EXIT.auth;
842
1016
  try {
843
1017
  const response = await authed.client.closeReplies(requestId);
1018
+ forgetClosedQuestion(deps, requestId);
844
1019
  if (flags.json) {
845
1020
  deps.io.out(JSON.stringify({
846
1021
  ...response,
@@ -858,6 +1033,149 @@ export async function closeCommand(deps, requestId, flags = {}) {
858
1033
  return reportError(deps, err);
859
1034
  }
860
1035
  }
1036
+ /**
1037
+ * Retire this session's outstanding questions, including registrations the
1038
+ * Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
1039
+ * them is the whole retirement. Live ones still need their reply window closed.
1040
+ */
1041
+ async function closePendingQuestions(deps, json) {
1042
+ const lifecycleSession = resolveCommandSession(deps);
1043
+ if (lifecycleSession === null) {
1044
+ deps.io.err('No active session pointer is available in this directory.');
1045
+ return EXIT.noReply;
1046
+ }
1047
+ const sessionId = lifecycleSession.sessionId;
1048
+ const pending = readSessionState(sessionId, deps.env).pending ?? [];
1049
+ if (pending.length === 0) {
1050
+ if (json) {
1051
+ deps.io.out(JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2));
1052
+ }
1053
+ else {
1054
+ deps.io.err(`Session ${sessionId} has no registered question pending.`);
1055
+ }
1056
+ return EXIT.noReply;
1057
+ }
1058
+ const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env);
1059
+ const live = (readSessionState(sessionId, deps.env).pending ?? []).filter((entry) => entry.request_id !== undefined);
1060
+ const closed = [];
1061
+ if (live.length > 0) {
1062
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId });
1063
+ const authed = authedClient(deps, config);
1064
+ if (!authed)
1065
+ return EXIT.auth;
1066
+ for (const entry of live) {
1067
+ try {
1068
+ await authed.client.closeReplies(entry.request_id);
1069
+ parkClosedQuestion(sessionId, deps.env, entry);
1070
+ dropPendingQuestion(sessionId, deps.env, entry);
1071
+ closed.push(entry.request_id);
1072
+ }
1073
+ catch (err) {
1074
+ return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
1075
+ }
1076
+ }
1077
+ }
1078
+ const output = {
1079
+ session_id: sessionId,
1080
+ withdrawn: withdrawn.map((entry) => ({
1081
+ question: entry.question,
1082
+ ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
1083
+ })),
1084
+ closed,
1085
+ };
1086
+ if (json)
1087
+ deps.io.out(JSON.stringify(output, null, 2));
1088
+ else {
1089
+ if (withdrawn.length > 0) {
1090
+ deps.io.out(withdrawn.length === 1
1091
+ ? 'Withdrew 1 unpushed question so a later Stop will not send it.'
1092
+ : `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`);
1093
+ }
1094
+ for (const requestId of closed) {
1095
+ deps.io.out(`Closed the reply window for ${requestId}.`);
1096
+ }
1097
+ }
1098
+ return EXIT.ok;
1099
+ }
1100
+ /**
1101
+ * Close one outstanding question by the stable id `notifai ask` returned, or
1102
+ * by a request id already on a device. Unpushed entries only exist locally.
1103
+ * Returns null when this directory's session has no matching registration, so
1104
+ * the caller can still close a live request id against the service.
1105
+ */
1106
+ async function closeLocalQuestion(deps, id, json) {
1107
+ const lifecycleSession = resolveCommandSession(deps, id);
1108
+ if (lifecycleSession === null)
1109
+ return null;
1110
+ const sessionId = lifecycleSession.sessionId;
1111
+ const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.question_id === id || candidate.request_id === id);
1112
+ if (entry === undefined)
1113
+ return null;
1114
+ if (entry.request_id === undefined) {
1115
+ dropPendingQuestion(sessionId, deps.env, entry);
1116
+ const output = {
1117
+ session_id: sessionId,
1118
+ withdrawn: [
1119
+ {
1120
+ question: entry.question,
1121
+ ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
1122
+ },
1123
+ ],
1124
+ closed: [],
1125
+ };
1126
+ if (json)
1127
+ deps.io.out(JSON.stringify(output, null, 2));
1128
+ else {
1129
+ deps.io.out(`Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`);
1130
+ }
1131
+ return EXIT.ok;
1132
+ }
1133
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId });
1134
+ const authed = authedClient(deps, config);
1135
+ if (!authed)
1136
+ return EXIT.auth;
1137
+ try {
1138
+ const response = await authed.client.closeReplies(entry.request_id);
1139
+ parkClosedQuestion(sessionId, deps.env, entry);
1140
+ dropPendingQuestion(sessionId, deps.env, entry);
1141
+ if (json) {
1142
+ deps.io.out(JSON.stringify({
1143
+ ...response,
1144
+ question_id: entry.question_id,
1145
+ acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
1146
+ }, null, 2));
1147
+ }
1148
+ else {
1149
+ deps.io.out(`Closed the reply window for ${entry.request_id}.`);
1150
+ if (response.replies.length > 0)
1151
+ printAcknowledgementStatus(deps, response);
1152
+ }
1153
+ return EXIT.ok;
1154
+ }
1155
+ catch (err) {
1156
+ return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
1157
+ }
1158
+ }
1159
+ function forgetClosedQuestion(deps, requestId) {
1160
+ const lifecycleSession = resolveCommandSession(deps, requestId);
1161
+ if (lifecycleSession === null)
1162
+ return;
1163
+ const sessionId = lifecycleSession.sessionId;
1164
+ const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.request_id === requestId);
1165
+ if (entry === undefined)
1166
+ return;
1167
+ parkClosedQuestion(sessionId, deps.env, entry);
1168
+ dropPendingQuestion(sessionId, deps.env, entry);
1169
+ }
1170
+ function parkClosedQuestion(sessionId, env, entry) {
1171
+ try {
1172
+ parkForRetirement(sessionId, env, entry, 'expired');
1173
+ }
1174
+ catch {
1175
+ // Incomplete identifiers cannot be retired on a device; dropping the
1176
+ // local record is still the right way to stop a later Stop from waiting.
1177
+ }
1178
+ }
861
1179
  /** True when this process is `npx` / `npm exec`, not a global or linked install. */
862
1180
  export function runningViaNpx(env, scriptPath) {
863
1181
  if (env['npm_command'] === 'exec')
@@ -891,12 +1209,19 @@ function resolveHookAdapterTarget(deps, flags) {
891
1209
  }
892
1210
  function printHooksInstallClose(deps, harness, file) {
893
1211
  const label = HARNESS_LABELS[harness];
1212
+ const activation = harness === 'codex'
1213
+ ? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
1214
+ : harness === 'cursor'
1215
+ ? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
1216
+ : harness === 'opencode'
1217
+ ? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
1218
+ : `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`;
894
1219
  if (deps.io.interactive === true && deps.io.note) {
895
- void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`);
1220
+ void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`);
896
1221
  return;
897
1222
  }
898
1223
  deps.io.out(`Installed ${harness} hooks in ${file}`);
899
- deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
1224
+ deps.io.out(activation);
900
1225
  }
901
1226
  export function hooksInstallCommand(deps, flags) {
902
1227
  if (flags.harness === undefined) {
@@ -931,7 +1256,18 @@ export function hooksInstallCommand(deps, flags) {
931
1256
  const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
932
1257
  const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
933
1258
  if (!wantGlobal && otherScope.some((installation) => installation.global)) {
934
- const globalFile = otherScope.find((installation) => installation.global)?.file;
1259
+ const globalInstallations = otherScope.filter((installation) => installation.global);
1260
+ const globalInstallation = globalInstallations[0];
1261
+ const installedEvents = new Set(globalInstallation?.handlers
1262
+ .map((handler) => handlerEvent(handler.command))
1263
+ .filter((event) => event !== null && HOOK_EVENTS.includes(event)) ?? []);
1264
+ if (globalInstallations.length !== 1 ||
1265
+ globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
1266
+ globalInstallations.some((installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0) ||
1267
+ !requiredHookEvents(harness).every((event) => installedEvents.has(event))) {
1268
+ return hooksInstallCommand(deps, { ...flags, global: true, harness });
1269
+ }
1270
+ const globalFile = globalInstallation?.file;
935
1271
  deps.io.out(`${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}`);
936
1272
  return EXIT.ok;
937
1273
  }
@@ -992,7 +1328,21 @@ export function hooksInstallCommand(deps, flags) {
992
1328
  installed =
993
1329
  codexPaths === null
994
1330
  ? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
995
- : withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget));
1331
+ : withCodexLayerTransaction(codexPaths, (inspection) => {
1332
+ const staleTarget = inspection.writeTarget === inspection.paths.hooksJson
1333
+ ? inspection.paths.configToml
1334
+ : inspection.paths.hooksJson;
1335
+ const staleEvents = staleTarget === inspection.paths.hooksJson
1336
+ ? inspection.ourJsonEvents
1337
+ : inspection.ourTomlEvents;
1338
+ if (staleEvents.length > 0) {
1339
+ const staleDocument = loadSettings(staleTarget);
1340
+ const stripped = removeHooks(staleDocument, scriptPath);
1341
+ if (stripped.replaced.length > 0)
1342
+ applyPlan(staleTarget, stripped.document);
1343
+ }
1344
+ return installInto(inspection.writeTarget);
1345
+ });
996
1346
  }
997
1347
  catch (err) {
998
1348
  deps.io.err(String(err));
@@ -1028,7 +1378,7 @@ function foreignStopHandlers(document) {
1028
1378
  return [];
1029
1379
  return groups
1030
1380
  .flatMap((group) => group.hooks ?? [])
1031
- .filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
1381
+ .filter((handler) => !/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(handler.command));
1032
1382
  }
1033
1383
  /**
1034
1384
  * Writes the OpenCode plugin, replacing any Notifai plugin already there —
@@ -1146,7 +1496,11 @@ export function hooksUninstallCommand(deps, flags) {
1146
1496
  result =
1147
1497
  codexPaths === null
1148
1498
  ? removeInstalledHooks()
1149
- : withCodexLayerTransaction(codexPaths, removeInstalledHooks);
1499
+ : withCodexLayerTransaction(codexPaths, (inspection) => {
1500
+ const stripped = removeInstalledHooks();
1501
+ cleanupEmptiedCodexLayer(inspection.paths);
1502
+ return stripped;
1503
+ });
1150
1504
  }
1151
1505
  catch (err) {
1152
1506
  deps.io.err(String(err));