@raidiant/notifai 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -9,28 +9,45 @@ 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, readMatchingProjectSessionPointer, readProjectSession, 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';
22
24
  // ---------------------------------------------------------------------------
23
25
  // hook / ask / close — harness integration
24
26
  // ---------------------------------------------------------------------------
25
- export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
27
+ export const HOOK_EVENTS = [
28
+ 'session-start',
29
+ 'subagent-start',
30
+ 'activation-stop',
31
+ 'user-prompt-submit',
32
+ 'stop',
33
+ 'session-end',
34
+ ];
35
+ /** Lifecycle handlers one installed harness must carry in this CLI build. */
36
+ export function requiredHookEvents(harness) {
37
+ if (harness === 'opencode')
38
+ return [];
39
+ return harness === 'cursor'
40
+ ? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
41
+ : ['session-start', 'subagent-start', 'user-prompt-submit', 'stop', 'session-end'];
42
+ }
26
43
  /** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
27
44
  export function hookDefersDiagnosticsUntilAfterCleanup(event) {
28
45
  return event === 'session-end';
29
46
  }
30
47
  /**
31
48
  * 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".
49
+ * stdin, harness output (if any) goes to stdout, diagnostics go to stderr, and
50
+ * exit 0 with no stdout means "no decision or added context — carry on as normal".
34
51
  *
35
52
  * Every failure path in here must reach that no-decision state. A hook that
36
53
  * throws, or that blocks past the harness's timeout, degrades the agent for a
@@ -63,6 +80,20 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
63
80
  const failureData = (err) => err instanceof ApiCallError
64
81
  ? { status: err.status, code: err.code, message: err.message, details: err.details }
65
82
  : { message: err instanceof Error ? err.message : String(err) };
83
+ // Cursor may load ~/.claude/settings.json in addition to its own native
84
+ // hooks. Cursor guarantees CURSOR_PROJECT_DIR to hook processes, so a Claude
85
+ // compatibility copy becomes a no-op and the native Cursor definition is
86
+ // the single owner. Real Claude hooks do not receive that hook-only marker.
87
+ if (harness === 'claude-code' && deps.env['CURSOR_PROJECT_DIR']) {
88
+ start({ outcome: 'cursor-compatibility-copy-skipped' });
89
+ logger.info('hook.end', {
90
+ hook: event,
91
+ outcome: 'ignored',
92
+ reason: 'cursor-native-handler-owns-event',
93
+ decided: false,
94
+ });
95
+ return EXIT.ok;
96
+ }
66
97
  let raw;
67
98
  try {
68
99
  raw = await readStdin();
@@ -105,6 +136,164 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
105
136
  const cwd = envelope.cwd ?? deps.cwd;
106
137
  const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
107
138
  logger.bind({ session: envelope.session_id ?? null });
139
+ const declaredSourcePid = declaredHookSourcePid(deps);
140
+ const activationOwnerPid = harness === 'claude-code'
141
+ ? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
142
+ : harness === 'codex'
143
+ ? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
144
+ : undefined;
145
+ // Session activation is local model context, not authenticated routing. It
146
+ // must survive the exact first-run states it exists to repair: no config,
147
+ // no credential, no Companion device, and no network.
148
+ if (event === 'session-start' || event === 'subagent-start') {
149
+ start({ cwd, source: envelope.source ?? null });
150
+ if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
151
+ try {
152
+ resetCursorStopActivation(envelope.session_id, deps.env);
153
+ }
154
+ catch (err) {
155
+ logger.error('hook.end', {
156
+ hook: event,
157
+ outcome: 'reset-failed',
158
+ ...failureData(err),
159
+ });
160
+ }
161
+ }
162
+ const stdout = sessionActivationOutput(harness, event === 'session-start' ? 'SessionStart' : 'SubagentStart');
163
+ if (stdout !== undefined)
164
+ deps.io.out(stdout);
165
+ if (stdout !== undefined &&
166
+ event === 'session-start' &&
167
+ activationOwnerPid !== undefined &&
168
+ envelope.session_id !== undefined) {
169
+ try {
170
+ // Write stdout first. If the process dies between these operations a
171
+ // later prompt may duplicate context, but it cannot suppress context
172
+ // that the host never received.
173
+ markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid);
174
+ }
175
+ catch (err) {
176
+ logger.error('hook.end', {
177
+ hook: event,
178
+ outcome: 'mark-failed',
179
+ reason: 'activation-state-failed',
180
+ ...failureData(err),
181
+ });
182
+ }
183
+ }
184
+ logger.info('hook.end', {
185
+ hook: event,
186
+ outcome: stdout === undefined ? 'unsupported-harness' : 'context-added',
187
+ decided: false,
188
+ });
189
+ return EXIT.ok;
190
+ }
191
+ // SessionStart is the preferred model-visible activation seam. A few
192
+ // managed hosts regenerate older hook definitions that retain
193
+ // UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
194
+ // once per exact session as a compatibility fallback, before config/auth/
195
+ // device/network work, and stay silent when SessionStart already ran.
196
+ let promptActivationStdout;
197
+ if (event === 'user-prompt-submit' &&
198
+ (harness === 'claude-code' || harness === 'codex') &&
199
+ activationOwnerPid !== undefined &&
200
+ envelope.session_id !== undefined) {
201
+ try {
202
+ if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid)) {
203
+ promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit');
204
+ if (promptActivationStdout !== undefined)
205
+ deps.io.out(promptActivationStdout);
206
+ }
207
+ }
208
+ catch (err) {
209
+ logger.error('hook.end', {
210
+ hook: event,
211
+ outcome: 'claim-failed',
212
+ reason: 'activation-state-failed',
213
+ ...failureData(err),
214
+ });
215
+ }
216
+ }
217
+ // Cursor has a confirmed host bug in which sessionStart.additional_context
218
+ // is accepted but never reaches the model. A native Stop follow-up is the
219
+ // host's guaranteed model-visible channel. Claim it once per conversation,
220
+ // before config/auth/network, and leave the ordinary Stop handler separate
221
+ // so question delivery is never displaced by activation.
222
+ if (event === 'activation-stop') {
223
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
224
+ if (harness !== 'cursor') {
225
+ logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false });
226
+ return EXIT.ok;
227
+ }
228
+ if (envelope.status === 'aborted') {
229
+ logger.info('hook.end', {
230
+ hook: event,
231
+ outcome: 'ignored',
232
+ reason: `turn-${envelope.status}`,
233
+ decided: false,
234
+ });
235
+ return EXIT.ok;
236
+ }
237
+ if (envelope.session_id === undefined) {
238
+ logger.info('hook.end', {
239
+ hook: event,
240
+ outcome: 'ignored',
241
+ reason: 'missing-conversation-id',
242
+ decided: false,
243
+ });
244
+ return EXIT.ok;
245
+ }
246
+ if (envelope.session_id !== undefined &&
247
+ typeof envelope.loop_count === 'number' &&
248
+ envelope.loop_count > 0) {
249
+ let activationOwned = false;
250
+ try {
251
+ activationOwned = confirmCursorStopActivation(envelope.session_id, deps.env, now());
252
+ }
253
+ catch (err) {
254
+ logger.error('hook.end', {
255
+ hook: event,
256
+ outcome: 'confirm-failed',
257
+ ...failureData(err),
258
+ });
259
+ }
260
+ if (activationOwned) {
261
+ logger.info('hook.end', { hook: event, outcome: 'confirmed', decided: false });
262
+ return EXIT.ok;
263
+ }
264
+ }
265
+ const cursorState = readSessionState(envelope.session_id, deps.env);
266
+ if ((cursorState.pending?.length ?? 0) > 0 ||
267
+ cursorState.accepted !== undefined ||
268
+ (cursorState.acknowledgement_due?.length ?? 0) > 0) {
269
+ logger.info('hook.end', {
270
+ hook: event,
271
+ outcome: 'deferred',
272
+ reason: 'question-continuation-owns-stop',
273
+ decided: false,
274
+ });
275
+ return EXIT.ok;
276
+ }
277
+ let claimed = false;
278
+ try {
279
+ claimed = claimCursorStopActivation(envelope.session_id, deps.env, now());
280
+ }
281
+ catch (err) {
282
+ logger.error('hook.end', {
283
+ hook: event,
284
+ outcome: 'claim-failed',
285
+ ...failureData(err),
286
+ });
287
+ }
288
+ logger.info('hook.end', {
289
+ hook: event,
290
+ outcome: claimed ? 'followup-added' : 'already-activated',
291
+ decided: claimed,
292
+ });
293
+ if (claimed)
294
+ deps.io.out(cursorStopActivationOutput());
295
+ return EXIT.ok;
296
+ }
108
297
  let config = null;
109
298
  let configFailure;
110
299
  try {
@@ -127,6 +316,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
127
316
  hook: event,
128
317
  outcome: 'failed',
129
318
  reason: 'config-failed',
319
+ ...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
130
320
  ...failureData(err),
131
321
  });
132
322
  for (const line of describeHookFailure(err))
@@ -174,9 +364,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
174
364
  // repository can commit `.notifai/config.toml`, and honouring a base_url
175
365
  // from it would hand this machine's bearer token to whatever host it names.
176
366
  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
- }
367
+ diagnoseIgnoredOriginOverride(deps.io, resolved, credential);
180
368
  // UserPromptSubmit runs in front of the user's own prompt under a 15s
181
369
  // harness ceiling and can make two calls, so each gets a small slice of it;
182
370
  // Stop is allowed to block and keeps the ordinary budget.
@@ -222,9 +410,10 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
222
410
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
223
411
  logger.info('hook.end', {
224
412
  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,
413
+ // Stop stdout takes over the turn. UserPromptSubmit stdout may instead
414
+ // add context to the user's own turn, which is explicitly non-decisive.
415
+ decided: outcome.decided ?? outcome.stdout !== undefined,
416
+ ...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
228
417
  ...(notes.length === 0 ? {} : { notes }),
229
418
  ...outcome.log,
230
419
  });
@@ -264,13 +453,14 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
264
453
  function stopWakeRoute(deps, harness, sessionId, cwd) {
265
454
  if (sessionId === undefined)
266
455
  return undefined;
456
+ const declaredSourcePid = declaredHookSourcePid(deps);
267
457
  if (harness === 'claude-code') {
268
458
  if ((deps.hookPlatform ?? process.platform) === 'win32')
269
459
  return undefined;
270
460
  return claudeWakeRoute({
271
461
  sessionId,
272
462
  cwd,
273
- sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
463
+ sourcePid: deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env),
274
464
  ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
275
465
  });
276
466
  }
@@ -278,13 +468,18 @@ function stopWakeRoute(deps, harness, sessionId, cwd) {
278
468
  return codexWakeRoute({
279
469
  threadId: sessionId,
280
470
  cwd,
281
- sourcePid: deps.codexSourcePid ?? process.ppid,
471
+ sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
282
472
  env: deps.env,
283
473
  ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
284
474
  });
285
475
  }
286
476
  return undefined;
287
477
  }
478
+ /** Stable harness parent propagated by the managed adapter across child tools. */
479
+ function declaredHookSourcePid(deps) {
480
+ const value = Number(deps.env['NOTIFAI_HOOK_SOURCE_PID']);
481
+ return Number.isInteger(value) && value > 0 ? value : undefined;
482
+ }
288
483
  /**
289
484
  * What went wrong, in terms of what to do about it.
290
485
  *
@@ -449,8 +644,9 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
449
644
  return { ok: true, draft: result.draft };
450
645
  }
451
646
  function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
647
+ let questionId;
452
648
  try {
453
- registerQuestion(sessionId, deps.env, {
649
+ questionId = registerQuestion(sessionId, deps.env, {
454
650
  question: built.questions[0].text,
455
651
  questions: built.questions,
456
652
  body: draft.presentation.body,
@@ -467,8 +663,9 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
467
663
  log(deps).info('ask.registered', {
468
664
  ok: true,
469
665
  session: sessionId,
666
+ question_id: questionId,
470
667
  questions: built.questions.length,
471
- text: built.questions[0].text,
668
+ text_chars: built.questions[0].text.length,
472
669
  choices: built.questions[0].choices?.length ?? 0,
473
670
  media: draft.presentation.media?.length ?? 0,
474
671
  });
@@ -479,17 +676,20 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
479
676
  if (json) {
480
677
  deps.io.out(JSON.stringify({
481
678
  registered: true,
679
+ question_id: questionId,
482
680
  questions: built.questions.map((entry) => ({
483
681
  id: entry.id,
484
682
  text: entry.text,
485
683
  ...(entry.choices === undefined ? {} : { choices: entry.choices }),
486
684
  ...(entry.multi === true ? { multi: true } : {}),
487
685
  })),
686
+ close: `notifai close ${questionId}`,
488
687
  next: {
489
688
  end_turn: true,
490
689
  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
690
  route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
492
691
  on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
692
+ 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
693
  },
494
694
  }, null, 2));
495
695
  return EXIT.ok;
@@ -505,8 +705,8 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
505
705
  }
506
706
  }
507
707
  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.');
708
+ ? `${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.`
709
+ : `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`);
510
710
  deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
511
711
  for (const [index, entry] of built.questions.entries()) {
512
712
  const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
@@ -522,6 +722,7 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
522
722
  }
523
723
  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
724
  deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
725
+ 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
726
  return EXIT.ok;
526
727
  }
527
728
  async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
@@ -534,7 +735,7 @@ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation)
534
735
  mediaIds.push(image);
535
736
  continue;
536
737
  }
537
- const uploaded = await uploadImage(deps, authed.client, image);
738
+ const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value);
538
739
  if (!uploaded.ok) {
539
740
  if (uploaded.error !== null)
540
741
  deps.io.err(uploaded.error);
@@ -558,6 +759,11 @@ export function askCommand(deps, question, flags) {
558
759
  // Validate before route discovery. A malformed question belongs to the
559
760
  // caller and should not be hidden behind whichever harness setup issue
560
761
  // happens to exist on this machine.
762
+ const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN);
763
+ if (escapedBody !== null) {
764
+ deps.io.err(escapedBody);
765
+ return EXIT.usage;
766
+ }
561
767
  const built = buildQuestions(flags, question);
562
768
  if (!built.ok) {
563
769
  deps.io.err(built.error);
@@ -756,19 +962,19 @@ export function hookActivationAdvice(installations) {
756
962
  const harnesses = new Set(installations.map((installation) => installation.harness));
757
963
  const advice = [];
758
964
  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');
965
+ advice.push('Claude Code: start one fresh session, send one prompt, then run `notifai doctor`');
760
966
  }
761
967
  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');
968
+ advice.push('Claude Code global hooks: start one fresh session, send one prompt, then run `notifai doctor`');
763
969
  }
764
970
  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');
971
+ advice.push('Cursor: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
766
972
  }
767
973
  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');
974
+ advice.push('Cursor global hooks: start one fresh conversation, send one prompt, finish its first turn, then run `notifai doctor`');
769
975
  }
770
976
  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');
977
+ advice.push('Codex: approve the Notifai handlers in `/hooks` if asked, start one fresh session, send one prompt, then run `notifai doctor`');
772
978
  }
773
979
  if (harnesses.has('opencode')) {
774
980
  advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
@@ -797,7 +1003,6 @@ export async function acknowledgeCommand(deps, requestId, flags) {
797
1003
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
798
1004
  logger.info('acknowledgement.attempted', {
799
1005
  request_id: requestId,
800
- text,
801
1006
  characters: text.length,
802
1007
  });
803
1008
  const authed = authedClient(deps, config);
@@ -814,7 +1019,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
814
1019
  logger.info('acknowledgement.outcome', {
815
1020
  request_id: requestId,
816
1021
  outcome: result.status,
817
- text: result.agent_acknowledgement.text,
1022
+ text_chars: result.agent_acknowledgement.text.length,
818
1023
  created_at: result.agent_acknowledgement.created_at,
819
1024
  agent_acknowledgement_required: true,
820
1025
  });
@@ -835,12 +1040,27 @@ export async function acknowledgeCommand(deps, requestId, flags) {
835
1040
  }
836
1041
  /** Retire a question so a late answer is rejected rather than silently lost. */
837
1042
  export async function closeCommand(deps, requestId, flags = {}) {
1043
+ if (flags.pending === true && requestId !== undefined) {
1044
+ deps.io.err('Pass a request id or --pending, not both.');
1045
+ return EXIT.usage;
1046
+ }
1047
+ if (flags.pending !== true && requestId === undefined) {
1048
+ deps.io.err('Pass a request id or --pending.');
1049
+ return EXIT.usage;
1050
+ }
1051
+ if (flags.pending === true) {
1052
+ return closePendingQuestions(deps, flags.json === true);
1053
+ }
1054
+ const local = await closeLocalQuestion(deps, requestId, flags.json === true);
1055
+ if (local !== null)
1056
+ return local;
838
1057
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
839
1058
  const authed = authedClient(deps, config);
840
1059
  if (!authed)
841
1060
  return EXIT.auth;
842
1061
  try {
843
1062
  const response = await authed.client.closeReplies(requestId);
1063
+ forgetClosedQuestion(deps, requestId);
844
1064
  if (flags.json) {
845
1065
  deps.io.out(JSON.stringify({
846
1066
  ...response,
@@ -858,6 +1078,148 @@ export async function closeCommand(deps, requestId, flags = {}) {
858
1078
  return reportError(deps, err);
859
1079
  }
860
1080
  }
1081
+ /**
1082
+ * Retire this session's outstanding questions, including registrations the
1083
+ * Stop hook has not pushed yet. Unpushed entries only exist locally: dropping
1084
+ * them is the whole retirement. Live ones still need their reply window closed.
1085
+ */
1086
+ async function closePendingQuestions(deps, json) {
1087
+ const now = (deps.now ?? Date.now)();
1088
+ const sessionId = readProjectSession(deps.cwd, deps.env, now);
1089
+ if (sessionId === null) {
1090
+ deps.io.err('No active session pointer is available in this directory.');
1091
+ return EXIT.noReply;
1092
+ }
1093
+ const pending = readSessionState(sessionId, deps.env).pending ?? [];
1094
+ if (pending.length === 0) {
1095
+ if (json) {
1096
+ deps.io.out(JSON.stringify({ session_id: sessionId, withdrawn: [], closed: [] }, null, 2));
1097
+ }
1098
+ else {
1099
+ deps.io.err(`Session ${sessionId} has no registered question pending.`);
1100
+ }
1101
+ return EXIT.noReply;
1102
+ }
1103
+ const withdrawn = withdrawUnpushedQuestions(sessionId, deps.env);
1104
+ const live = (readSessionState(sessionId, deps.env).pending ?? []).filter((entry) => entry.request_id !== undefined);
1105
+ const closed = [];
1106
+ if (live.length > 0) {
1107
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1108
+ const authed = authedClient(deps, config);
1109
+ if (!authed)
1110
+ return EXIT.auth;
1111
+ for (const entry of live) {
1112
+ try {
1113
+ await authed.client.closeReplies(entry.request_id);
1114
+ parkClosedQuestion(sessionId, deps.env, entry);
1115
+ dropPendingQuestion(sessionId, deps.env, entry);
1116
+ closed.push(entry.request_id);
1117
+ }
1118
+ catch (err) {
1119
+ return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
1120
+ }
1121
+ }
1122
+ }
1123
+ const output = {
1124
+ session_id: sessionId,
1125
+ withdrawn: withdrawn.map((entry) => ({
1126
+ question: entry.question,
1127
+ ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
1128
+ })),
1129
+ closed,
1130
+ };
1131
+ if (json)
1132
+ deps.io.out(JSON.stringify(output, null, 2));
1133
+ else {
1134
+ if (withdrawn.length > 0) {
1135
+ deps.io.out(withdrawn.length === 1
1136
+ ? 'Withdrew 1 unpushed question so a later Stop will not send it.'
1137
+ : `Withdrew ${withdrawn.length} unpushed questions so a later Stop will not send them.`);
1138
+ }
1139
+ for (const requestId of closed) {
1140
+ deps.io.out(`Closed the reply window for ${requestId}.`);
1141
+ }
1142
+ }
1143
+ return EXIT.ok;
1144
+ }
1145
+ /**
1146
+ * Close one outstanding question by the stable id `notifai ask` returned, or
1147
+ * by a request id already on a device. Unpushed entries only exist locally.
1148
+ * Returns null when this directory's session has no matching registration, so
1149
+ * the caller can still close a live request id against the service.
1150
+ */
1151
+ async function closeLocalQuestion(deps, id, json) {
1152
+ const now = (deps.now ?? Date.now)();
1153
+ const sessionId = readProjectSession(deps.cwd, deps.env, now);
1154
+ if (sessionId === null)
1155
+ return null;
1156
+ const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.question_id === id || candidate.request_id === id);
1157
+ if (entry === undefined)
1158
+ return null;
1159
+ if (entry.request_id === undefined) {
1160
+ dropPendingQuestion(sessionId, deps.env, entry);
1161
+ const output = {
1162
+ session_id: sessionId,
1163
+ withdrawn: [
1164
+ {
1165
+ question: entry.question,
1166
+ ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
1167
+ },
1168
+ ],
1169
+ closed: [],
1170
+ };
1171
+ if (json)
1172
+ deps.io.out(JSON.stringify(output, null, 2));
1173
+ else {
1174
+ deps.io.out(`Withdrew unpushed question ${entry.question_id ?? id} so a later Stop will not send it.`);
1175
+ }
1176
+ return EXIT.ok;
1177
+ }
1178
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1179
+ const authed = authedClient(deps, config);
1180
+ if (!authed)
1181
+ return EXIT.auth;
1182
+ try {
1183
+ const response = await authed.client.closeReplies(entry.request_id);
1184
+ parkClosedQuestion(sessionId, deps.env, entry);
1185
+ dropPendingQuestion(sessionId, deps.env, entry);
1186
+ if (json) {
1187
+ deps.io.out(JSON.stringify({
1188
+ ...response,
1189
+ question_id: entry.question_id,
1190
+ acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
1191
+ }, null, 2));
1192
+ }
1193
+ else {
1194
+ deps.io.out(`Closed the reply window for ${entry.request_id}.`);
1195
+ if (response.replies.length > 0)
1196
+ printAcknowledgementStatus(deps, response);
1197
+ }
1198
+ return EXIT.ok;
1199
+ }
1200
+ catch (err) {
1201
+ return reportError(deps, err, { operation: 'close', request_id: entry.request_id });
1202
+ }
1203
+ }
1204
+ function forgetClosedQuestion(deps, requestId) {
1205
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
1206
+ if (sessionId === null)
1207
+ return;
1208
+ const entry = (readSessionState(sessionId, deps.env).pending ?? []).find((candidate) => candidate.request_id === requestId);
1209
+ if (entry === undefined)
1210
+ return;
1211
+ parkClosedQuestion(sessionId, deps.env, entry);
1212
+ dropPendingQuestion(sessionId, deps.env, entry);
1213
+ }
1214
+ function parkClosedQuestion(sessionId, env, entry) {
1215
+ try {
1216
+ parkForRetirement(sessionId, env, entry, 'expired');
1217
+ }
1218
+ catch {
1219
+ // Incomplete identifiers cannot be retired on a device; dropping the
1220
+ // local record is still the right way to stop a later Stop from waiting.
1221
+ }
1222
+ }
861
1223
  /** True when this process is `npx` / `npm exec`, not a global or linked install. */
862
1224
  export function runningViaNpx(env, scriptPath) {
863
1225
  if (env['npm_command'] === 'exec')
@@ -891,12 +1253,19 @@ function resolveHookAdapterTarget(deps, flags) {
891
1253
  }
892
1254
  function printHooksInstallClose(deps, harness, file) {
893
1255
  const label = HARNESS_LABELS[harness];
1256
+ const activation = harness === 'codex'
1257
+ ? 'Approve the Notifai handlers in `/hooks` if Codex asks, then start one fresh Codex session, send one prompt, and run `notifai doctor`.'
1258
+ : harness === 'cursor'
1259
+ ? 'Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.'
1260
+ : harness === 'opencode'
1261
+ ? 'Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.'
1262
+ : `Start one fresh ${label} session, send one prompt, then run \`notifai doctor\`.`;
894
1263
  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`);
1264
+ void deps.io.note(`${file}\n${activation}`, `${label} hooks installed`);
896
1265
  return;
897
1266
  }
898
1267
  deps.io.out(`Installed ${harness} hooks in ${file}`);
899
- deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
1268
+ deps.io.out(activation);
900
1269
  }
901
1270
  export function hooksInstallCommand(deps, flags) {
902
1271
  if (flags.harness === undefined) {
@@ -931,7 +1300,18 @@ export function hooksInstallCommand(deps, flags) {
931
1300
  const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
932
1301
  const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
933
1302
  if (!wantGlobal && otherScope.some((installation) => installation.global)) {
934
- const globalFile = otherScope.find((installation) => installation.global)?.file;
1303
+ const globalInstallations = otherScope.filter((installation) => installation.global);
1304
+ const globalInstallation = globalInstallations[0];
1305
+ const installedEvents = new Set(globalInstallation?.handlers
1306
+ .map((handler) => handlerEvent(handler.command))
1307
+ .filter((event) => event !== null && HOOK_EVENTS.includes(event)) ?? []);
1308
+ if (globalInstallations.length !== 1 ||
1309
+ globalInstallations.some((installation) => (installation.problems?.length ?? 0) > 0) ||
1310
+ globalInstallations.some((installation) => stopShapeProblems(installation, deps.hookPlatform).length > 0) ||
1311
+ !requiredHookEvents(harness).every((event) => installedEvents.has(event))) {
1312
+ return hooksInstallCommand(deps, { ...flags, global: true, harness });
1313
+ }
1314
+ const globalFile = globalInstallation?.file;
935
1315
  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
1316
  return EXIT.ok;
937
1317
  }
@@ -992,7 +1372,21 @@ export function hooksInstallCommand(deps, flags) {
992
1372
  installed =
993
1373
  codexPaths === null
994
1374
  ? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
995
- : withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget));
1375
+ : withCodexLayerTransaction(codexPaths, (inspection) => {
1376
+ const staleTarget = inspection.writeTarget === inspection.paths.hooksJson
1377
+ ? inspection.paths.configToml
1378
+ : inspection.paths.hooksJson;
1379
+ const staleEvents = staleTarget === inspection.paths.hooksJson
1380
+ ? inspection.ourJsonEvents
1381
+ : inspection.ourTomlEvents;
1382
+ if (staleEvents.length > 0) {
1383
+ const staleDocument = loadSettings(staleTarget);
1384
+ const stripped = removeHooks(staleDocument, scriptPath);
1385
+ if (stripped.replaced.length > 0)
1386
+ applyPlan(staleTarget, stripped.document);
1387
+ }
1388
+ return installInto(inspection.writeTarget);
1389
+ });
996
1390
  }
997
1391
  catch (err) {
998
1392
  deps.io.err(String(err));
@@ -1028,7 +1422,7 @@ function foreignStopHandlers(document) {
1028
1422
  return [];
1029
1423
  return groups
1030
1424
  .flatMap((group) => group.hooks ?? [])
1031
- .filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
1425
+ .filter((handler) => !/ hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(handler.command));
1032
1426
  }
1033
1427
  /**
1034
1428
  * Writes the OpenCode plugin, replacing any Notifai plugin already there —
@@ -1146,7 +1540,11 @@ export function hooksUninstallCommand(deps, flags) {
1146
1540
  result =
1147
1541
  codexPaths === null
1148
1542
  ? removeInstalledHooks()
1149
- : withCodexLayerTransaction(codexPaths, removeInstalledHooks);
1543
+ : withCodexLayerTransaction(codexPaths, (inspection) => {
1544
+ const stripped = removeInstalledHooks();
1545
+ cleanupEmptiedCodexLayer(inspection.paths);
1546
+ return stripped;
1547
+ });
1150
1548
  }
1151
1549
  catch (err) {
1152
1550
  deps.io.err(String(err));