@raidiant/notifai 7.0.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -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
  *
@@ -374,13 +569,12 @@ export function buildQuestions(flags, question) {
374
569
  questions.push(built.question);
375
570
  }
376
571
  const context = flags.body ?? record['body'];
377
- const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
378
572
  return {
379
573
  ok: true,
380
574
  questions,
381
575
  body: context !== undefined && context.trim() !== ''
382
- ? `${questionBlock}\n\n${context}`
383
- : questionBlock,
576
+ ? context
577
+ : questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n'),
384
578
  };
385
579
  }
386
580
  if (question === undefined || question.trim() === '') {
@@ -393,9 +587,7 @@ export function buildQuestions(flags, question) {
393
587
  return {
394
588
  ok: true,
395
589
  questions: [built.question],
396
- body: context !== undefined && context.trim() !== ''
397
- ? `${built.question.text}\n\n${context}`
398
- : built.question.text,
590
+ body: context !== undefined && context.trim() !== '' ? context : built.question.text,
399
591
  };
400
592
  }
401
593
  function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
@@ -452,8 +644,9 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
452
644
  return { ok: true, draft: result.draft };
453
645
  }
454
646
  function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
647
+ let questionId;
455
648
  try {
456
- registerQuestion(sessionId, deps.env, {
649
+ questionId = registerQuestion(sessionId, deps.env, {
457
650
  question: built.questions[0].text,
458
651
  questions: built.questions,
459
652
  body: draft.presentation.body,
@@ -470,8 +663,9 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
470
663
  log(deps).info('ask.registered', {
471
664
  ok: true,
472
665
  session: sessionId,
666
+ question_id: questionId,
473
667
  questions: built.questions.length,
474
- text: built.questions[0].text,
668
+ text_chars: built.questions[0].text.length,
475
669
  choices: built.questions[0].choices?.length ?? 0,
476
670
  media: draft.presentation.media?.length ?? 0,
477
671
  });
@@ -482,17 +676,20 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
482
676
  if (json) {
483
677
  deps.io.out(JSON.stringify({
484
678
  registered: true,
679
+ question_id: questionId,
485
680
  questions: built.questions.map((entry) => ({
486
681
  id: entry.id,
487
682
  text: entry.text,
488
683
  ...(entry.choices === undefined ? {} : { choices: entry.choices }),
489
684
  ...(entry.multi === true ? { multi: true } : {}),
490
685
  })),
686
+ close: `notifai close ${questionId}`,
491
687
  next: {
492
688
  end_turn: true,
493
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.',
494
690
  route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
495
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.`,
496
693
  },
497
694
  }, null, 2));
498
695
  return EXIT.ok;
@@ -508,8 +705,8 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
508
705
  }
509
706
  }
510
707
  deps.io.out(built.questions.length > 1
511
- ? `${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.`
512
- : '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.`);
513
710
  deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
514
711
  for (const [index, entry] of built.questions.entries()) {
515
712
  const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
@@ -525,6 +722,7 @@ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
525
722
  }
526
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.');
527
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.`);
528
726
  return EXIT.ok;
529
727
  }
530
728
  async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
@@ -537,7 +735,7 @@ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation)
537
735
  mediaIds.push(image);
538
736
  continue;
539
737
  }
540
- const uploaded = await uploadImage(deps, authed.client, image);
738
+ const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value);
541
739
  if (!uploaded.ok) {
542
740
  if (uploaded.error !== null)
543
741
  deps.io.err(uploaded.error);
@@ -561,6 +759,11 @@ export function askCommand(deps, question, flags) {
561
759
  // Validate before route discovery. A malformed question belongs to the
562
760
  // caller and should not be hidden behind whichever harness setup issue
563
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
+ }
564
767
  const built = buildQuestions(flags, question);
565
768
  if (!built.ok) {
566
769
  deps.io.err(built.error);
@@ -759,19 +962,19 @@ export function hookActivationAdvice(installations) {
759
962
  const harnesses = new Set(installations.map((installation) => installation.harness));
760
963
  const advice = [];
761
964
  if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
762
- 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`');
763
966
  }
764
967
  if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
765
- 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`');
766
969
  }
767
970
  if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
768
- 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`');
769
972
  }
770
973
  if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
771
- 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`');
772
975
  }
773
976
  if (harnesses.has('codex')) {
774
- 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`');
775
978
  }
776
979
  if (harnesses.has('opencode')) {
777
980
  advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
@@ -800,7 +1003,6 @@ export async function acknowledgeCommand(deps, requestId, flags) {
800
1003
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
801
1004
  logger.info('acknowledgement.attempted', {
802
1005
  request_id: requestId,
803
- text,
804
1006
  characters: text.length,
805
1007
  });
806
1008
  const authed = authedClient(deps, config);
@@ -817,7 +1019,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
817
1019
  logger.info('acknowledgement.outcome', {
818
1020
  request_id: requestId,
819
1021
  outcome: result.status,
820
- text: result.agent_acknowledgement.text,
1022
+ text_chars: result.agent_acknowledgement.text.length,
821
1023
  created_at: result.agent_acknowledgement.created_at,
822
1024
  agent_acknowledgement_required: true,
823
1025
  });
@@ -838,12 +1040,27 @@ export async function acknowledgeCommand(deps, requestId, flags) {
838
1040
  }
839
1041
  /** Retire a question so a late answer is rejected rather than silently lost. */
840
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;
841
1057
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
842
1058
  const authed = authedClient(deps, config);
843
1059
  if (!authed)
844
1060
  return EXIT.auth;
845
1061
  try {
846
1062
  const response = await authed.client.closeReplies(requestId);
1063
+ forgetClosedQuestion(deps, requestId);
847
1064
  if (flags.json) {
848
1065
  deps.io.out(JSON.stringify({
849
1066
  ...response,
@@ -861,6 +1078,148 @@ export async function closeCommand(deps, requestId, flags = {}) {
861
1078
  return reportError(deps, err);
862
1079
  }
863
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
+ }
864
1223
  /** True when this process is `npx` / `npm exec`, not a global or linked install. */
865
1224
  export function runningViaNpx(env, scriptPath) {
866
1225
  if (env['npm_command'] === 'exec')
@@ -894,12 +1253,19 @@ function resolveHookAdapterTarget(deps, flags) {
894
1253
  }
895
1254
  function printHooksInstallClose(deps, harness, file) {
896
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\`.`;
897
1263
  if (deps.io.interactive === true && deps.io.note) {
898
- 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`);
899
1265
  return;
900
1266
  }
901
1267
  deps.io.out(`Installed ${harness} hooks in ${file}`);
902
- deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
1268
+ deps.io.out(activation);
903
1269
  }
904
1270
  export function hooksInstallCommand(deps, flags) {
905
1271
  if (flags.harness === undefined) {
@@ -934,7 +1300,18 @@ export function hooksInstallCommand(deps, flags) {
934
1300
  const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
935
1301
  const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
936
1302
  if (!wantGlobal && otherScope.some((installation) => installation.global)) {
937
- 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;
938
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}`);
939
1316
  return EXIT.ok;
940
1317
  }
@@ -995,7 +1372,21 @@ export function hooksInstallCommand(deps, flags) {
995
1372
  installed =
996
1373
  codexPaths === null
997
1374
  ? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
998
- : 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
+ });
999
1390
  }
1000
1391
  catch (err) {
1001
1392
  deps.io.err(String(err));
@@ -1031,7 +1422,7 @@ function foreignStopHandlers(document) {
1031
1422
  return [];
1032
1423
  return groups
1033
1424
  .flatMap((group) => group.hooks ?? [])
1034
- .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));
1035
1426
  }
1036
1427
  /**
1037
1428
  * Writes the OpenCode plugin, replacing any Notifai plugin already there —
@@ -1149,7 +1540,11 @@ export function hooksUninstallCommand(deps, flags) {
1149
1540
  result =
1150
1541
  codexPaths === null
1151
1542
  ? removeInstalledHooks()
1152
- : withCodexLayerTransaction(codexPaths, removeInstalledHooks);
1543
+ : withCodexLayerTransaction(codexPaths, (inspection) => {
1544
+ const stripped = removeInstalledHooks();
1545
+ cleanupEmptiedCodexLayer(inspection.paths);
1546
+ return stripped;
1547
+ });
1153
1548
  }
1154
1549
  catch (err) {
1155
1550
  deps.io.err(String(err));