@raidiant/notifai 4.0.0 → 5.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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +72 -5
  17. package/dist/hooks.js +407 -79
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
package/CHANGELOG.md CHANGED
@@ -8,6 +8,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
9
9
  and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
10
10
 
11
+ ## [5.0.0](https://github.com/Raidiant-io/notifai/compare/v4.0.0...v5.0.0) (2026-08-16)
12
+
13
+
14
+ ### ⚠ BREAKING CHANGES
15
+
16
+ * `notifai send` now requires --kind. The account preference `agent_acknowledgements_enabled` is renamed to `agent_acknowledgement_text_enabled` and governs only the written reply.
17
+
18
+ ### Features
19
+
20
+ * **cli:** report an ask registration as data, and stop init promising routing it lacks ([156159b](https://github.com/Raidiant-io/notifai/commit/156159b4d39b249a1be65ac86a9f7452ee9720d2))
21
+ * keep a question answerable for a day, and let the user choose ([ace207e](https://github.com/Raidiant-io/notifai/commit/ace207e04cfdc3a0ccda14dffc79a882a663034c))
22
+ * make kind required and carry attention, and always acknowledge an answer ([07e7c21](https://github.com/Raidiant-io/notifai/commit/07e7c216d52f3041e50ce5c7ec1ce4a3f8491e64))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * clean up what the help, the README and a test comment were saying ([221ca6b](https://github.com/Raidiant-io/notifai/commit/221ca6b1d04a80cbd8473d54cd50a6024dcf6c50))
28
+ * **cli:** retire drafts the server rejects as invalid ([4023e42](https://github.com/Raidiant-io/notifai/commit/4023e42e3aeb9f8674239ab0516ccf2527b1428c))
29
+ * **cli:** retire drafts the server rejects as invalid ([d676726](https://github.com/Raidiant-io/notifai/commit/d6767265e4718102f7c97c6ee939073e79b038a2))
30
+ * **cli:** stop --reply-window advertising the default it no longer has ([b9eeabc](https://github.com/Raidiant-io/notifai/commit/b9eeabcbafd9a8e84d2aa86f44b04ca4ece237f2))
31
+ * give the detached waiter the wall clock its host already allows ([3d4b111](https://github.com/Raidiant-io/notifai/commit/3d4b111afb4d35bd8c04121deee82b849dd0ade1))
32
+ * **hooks:** never write a Codex hooks.json Notifai does not own ([8fdd00c](https://github.com/Raidiant-io/notifai/commit/8fdd00c6018915fe2164800c5797a9c3cb92da68))
33
+ * **hooks:** never write a Codex hooks.json Notifai does not own ([8948519](https://github.com/Raidiant-io/notifai/commit/894851942db6283ff856025e7f7d2dd86695013d))
34
+ * **hooks:** read the duplicate-representation warning as a sentence ([e0d041f](https://github.com/Raidiant-io/notifai/commit/e0d041f729e75a4c909f41c5dfafe6602f88edeb))
35
+ * **hooks:** read the duplicate-representation warning as a sentence ([936f068](https://github.com/Raidiant-io/notifai/commit/936f068d2587d56fc10a89a7d87c81c37ef50e2d))
36
+ * **hooks:** report a Codex dual representation only when Notifai is in both ([f4426dc](https://github.com/Raidiant-io/notifai/commit/f4426dc6e3876ac0a75f31ce5fb81c4ec2f4719d))
37
+ * **hooks:** report a Codex dual representation only when Notifai is in both ([2bab419](https://github.com/Raidiant-io/notifai/commit/2bab41987bf9fa251f71785b9881e3c8418be1bb))
38
+ * **hooks:** stop rewriting the whole of a user's Codex config.toml ([e037e22](https://github.com/Raidiant-io/notifai/commit/e037e227646178a0cbc08e7a00ab47129b3b435f))
39
+ * **hooks:** stop rewriting the whole of a user's Codex config.toml ([92ff189](https://github.com/Raidiant-io/notifai/commit/92ff1891c3f42092758af257ec3be8b68e51c0f1))
40
+ * let a question outlive the waiter that pushed it ([de242e7](https://github.com/Raidiant-io/notifai/commit/de242e753434dda87f665de6474491320fbc7365))
41
+ * **lock:** survive losing the rendezvous while registering ([2b5d46d](https://github.com/Raidiant-io/notifai/commit/2b5d46d5221b843fc2db5a803da561e3dbb3b7b0))
42
+ * **lock:** survive losing the rendezvous while registering ([efb8bd6](https://github.com/Raidiant-io/notifai/commit/efb8bd6f718f9f5e7e9bda1471cb88db0a77ef4c))
43
+ * reject an unknown --platform locally, the way send already does ([7ba7678](https://github.com/Raidiant-io/notifai/commit/7ba7678214cc3c98919ece2f533ed40f080951f3))
44
+ * report a failed image upload like every other API failure ([be76c48](https://github.com/Raidiant-io/notifai/commit/be76c4808e6c37bb97d64b14b018b148c4887d94))
45
+ * stop a busy session discarding the answer it was too busy to take ([13ce700](https://github.com/Raidiant-io/notifai/commit/13ce7003c393741b82886910cb51b18d0a9f1065))
46
+ * stop a turn-ending question expiring with the waiter that pushed it ([dd2cda6](https://github.com/Raidiant-io/notifai/commit/dd2cda6a339d711bc4c6723b93ccf940207565a7))
47
+ * stop a typed prompt erasing an outstanding acknowledgement ([ed15e18](https://github.com/Raidiant-io/notifai/commit/ed15e18fffd893ef0c5ad27bc17d4f78b0a88b6c))
48
+ * stop several surfaces stating things that are not true ([56402b9](https://github.com/Raidiant-io/notifai/commit/56402b98b763bdce2fb75c79f8502a92968a0abb))
49
+
50
+
51
+ ### Dependencies
52
+
53
+ * The following workspace dependencies were updated
54
+ * dependencies
55
+ * @raidiant/notifai-protocol bumped from 1.0.0 to 2.0.0
56
+
11
57
  ## [4.0.0](https://github.com/Raidiant-io/notifai/compare/v3.0.1...v4.0.0) (2026-08-15)
12
58
 
13
59
 
package/README.md CHANGED
@@ -54,6 +54,11 @@ By default a question reaches your devices when the agent turn ends, whether or
54
54
  not you are at the keyboard (`ask_grace_seconds = 0`). Set a positive grace
55
55
  period for a terminal-only answer window first.
56
56
 
57
+ When you answer, the agent acknowledges it before it does anything else, so you
58
+ find out your reply landed and what it set in motion — not just that you sent
59
+ it. Answering from your phone and wondering whether anything happened is the
60
+ whole problem this solves.
61
+
57
62
  For a harness that cannot resume an idle agent turn, `notifai send
58
63
  --reply` blocks and waits for the answer instead.
59
64
 
@@ -202,6 +202,8 @@ export declare function hookRunCommand(deps: CommandDeps, event: string, readStd
202
202
  */
203
203
  export declare function describeHookFailure(err: unknown): string[];
204
204
  export interface AskFlags {
205
+ /** Emit the registration and its turn obligation as one JSON object. */
206
+ json?: boolean;
205
207
  choice?: string[];
206
208
  /** The single question is multi-select: several answers may be chosen. */
207
209
  multi?: boolean;
@@ -238,7 +240,14 @@ export declare function buildQuestions(flags: AskFlags, question: string | undef
238
240
  * for `ask_grace_seconds` before it reaches any device.
239
241
  */
240
242
  export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number | Promise<number>;
241
- /** Record the one Agent Acknowledgement associated with a replied-to request. */
243
+ /**
244
+ * Record the one Agent Acknowledgement associated with a replied-to request.
245
+ *
246
+ * `--text` is optional here because the acknowledgement is not: an account may
247
+ * turn the agent's written reply off, and the receipt must still be recorded so
248
+ * the user sees that an agent read the answer. The service holds the account's
249
+ * snapshot, so it — not this process — decides whether text was owed.
250
+ */
242
251
  export declare function acknowledgeCommand(deps: CommandDeps, requestId: string, flags: {
243
252
  text?: string;
244
253
  json?: boolean;
package/dist/commands.js CHANGED
@@ -3,19 +3,19 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, u
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
- import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
6
+ import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, PLATFORMS, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
7
7
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
8
8
  import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
9
- import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
9
+ import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
10
10
  import { acceptedValues, configInfo } from './config-schema.js';
11
11
  import { packageVersion, skillsSource } from './release.js';
12
12
  import { atomicWriteFileSync } from './atomic-file.js';
13
13
  import { withTargetFileLock } from './file-lock.js';
14
14
  import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
15
15
  import { firstBlocker, } from './readiness.js';
16
- import { WAITER_CEILING_SECONDS, clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
17
- import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
18
- import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.js';
16
+ import { waiterCeilingSeconds, clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, MIN_REPLY_WINDOW_SECONDS, } from './hooks.js';
17
+ import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, RECORD_LEVELS, } from './logging.js';
18
+ import { BLOCKING_STOP_TIMEOUT_SECONDS, stopHandlerIsDetached, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexCoexistenceNotes, codexHomeNote, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.js';
19
19
  import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
20
20
  import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
21
21
  import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
@@ -88,7 +88,9 @@ function reportError(deps, err, context = {}) {
88
88
  deps.io.err(`the server rejected: ${paths.join(', ')}`);
89
89
  // A 422 on a request this CLI built is not a user error: the two sides
90
90
  // disagree about the contract, in whichever direction. `doctor` says which.
91
- if (err.status === 422) {
91
+ // Codes that name a policy the account chose are the exception — the
92
+ // contract held, the body simply did not satisfy that account.
93
+ if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
92
94
  deps.io.err('this build sent a field the server did not accept — the CLI and server ' +
93
95
  'disagree about the contract; check with `notifai doctor`');
94
96
  }
@@ -314,6 +316,13 @@ export function canDeviceReceive(device) {
314
316
  return deviceCanReceive(device);
315
317
  }
316
318
  export async function capabilitiesCommand(deps, flags) {
319
+ // Locally, and with the same message `send` gives. Spending a round trip to
320
+ // have the server answer "Request validation failed" told the caller neither
321
+ // which flag was wrong nor what it accepts.
322
+ if (flags.platform !== undefined && !PLATFORMS.includes(flags.platform)) {
323
+ deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`);
324
+ return EXIT.usage;
325
+ }
317
326
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
318
327
  const credential = deps.store.load();
319
328
  const baseUrl = resolvedBaseUrl(config, credential);
@@ -371,6 +380,15 @@ export async function sendCommand(deps, flags) {
371
380
  deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`);
372
381
  return EXIT.usage;
373
382
  }
383
+ // Kind now decides the sound a notification arrives with, so it is asked for
384
+ // at the boundary rather than defaulted silently: an unlabelled send would
385
+ // reach the user as ordinary news whatever actually happened.
386
+ if (!flags.reply && flags.kind === undefined) {
387
+ deps.io.err('--kind is required: say what this notification is. ' +
388
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
389
+ '--reply makes it a question without --kind.');
390
+ return EXIT.usage;
391
+ }
374
392
  if (!flags.reply && flags.kind === 'question') {
375
393
  deps.io.err('--kind question requires --reply so the question can be answered.');
376
394
  return EXIT.usage;
@@ -404,9 +422,9 @@ export async function sendCommand(deps, flags) {
404
422
  if (flags.reply &&
405
423
  flags.replyWindow !== undefined &&
406
424
  (!Number.isInteger(flags.replyWindow) ||
407
- flags.replyWindow < 60 ||
425
+ flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
408
426
  flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
409
- deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
427
+ deps.io.err(`--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
410
428
  return EXIT.usage;
411
429
  }
412
430
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
@@ -435,7 +453,8 @@ export async function sendCommand(deps, flags) {
435
453
  }
436
454
  const uploaded = await uploadImage(deps, authed.client, image);
437
455
  if (!uploaded.ok) {
438
- deps.io.err(uploaded.error);
456
+ if (uploaded.error !== null)
457
+ deps.io.err(uploaded.error);
439
458
  return uploaded.exit;
440
459
  }
441
460
  mediaIds.push(uploaded.mediaId);
@@ -513,6 +532,7 @@ export async function sendCommand(deps, flags) {
513
532
  request_id: receipt.request_id,
514
533
  replies: [],
515
534
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
535
+ agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
516
536
  agent_acknowledgement: null,
517
537
  acknowledgement_command: null,
518
538
  degraded: false,
@@ -629,7 +649,15 @@ export async function repliesCommand(deps, requestedId, flags) {
629
649
  .map((entry) => entry.request_id)
630
650
  .filter((id) => id !== undefined);
631
651
  if (requestIds.length === 0) {
632
- deps.io.err(`Session ${sessionId} has no pushed question pending.`);
652
+ // `--json` is a promise about stdout, and an empty result is still a
653
+ // result: a caller that asked for machine-readable output must not have
654
+ // to parse an English sentence to learn there was nothing pending.
655
+ if (flags.json === true) {
656
+ deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2));
657
+ }
658
+ else {
659
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`);
660
+ }
633
661
  return EXIT.noReply;
634
662
  }
635
663
  }
@@ -748,6 +776,7 @@ export async function waitForReply(client, requestId, options) {
748
776
  request_id: requestId,
749
777
  reply_expires_at: null,
750
778
  agent_acknowledgement_required: false,
779
+ agent_acknowledgement_text_required: false,
751
780
  agent_acknowledgement: null,
752
781
  replies: [],
753
782
  },
@@ -786,9 +815,14 @@ function degradedWaitWarning(requestId) {
786
815
  function isNonNegativeInteger(value) {
787
816
  return Number.isInteger(value) && value >= 0;
788
817
  }
789
- function acknowledgementCommand(requestId, required, acknowledgement, hasReply = true) {
790
- return required && acknowledgement === null && hasReply
818
+ function acknowledgeInvocation(requestId, textRequired) {
819
+ return textRequired
791
820
  ? `notifai acknowledge ${requestId} --text <text>`
821
+ : `notifai acknowledge ${requestId}`;
822
+ }
823
+ function acknowledgementCommand(requestId, required, textRequired, acknowledgement, hasReply = true) {
824
+ return required && acknowledgement === null && hasReply
825
+ ? acknowledgeInvocation(requestId, textRequired)
792
826
  : null;
793
827
  }
794
828
  function replyResultJson(response, degraded) {
@@ -799,7 +833,8 @@ function replyResultJson(response, degraded) {
799
833
  replies: response.replies,
800
834
  agent_acknowledgement_required: response.agent_acknowledgement_required,
801
835
  agent_acknowledgement: response.agent_acknowledgement,
802
- acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement, response.replies.length > 0),
836
+ agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
837
+ acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
803
838
  degraded,
804
839
  };
805
840
  }
@@ -808,13 +843,19 @@ function printAcknowledgementStatus(deps, response) {
808
843
  deps.io.out('Agent Acknowledgement: not required for this request.');
809
844
  return;
810
845
  }
846
+ const textRequired = response.agent_acknowledgement_text_required;
811
847
  if (response.agent_acknowledgement !== null) {
812
- deps.io.out(`Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`);
848
+ const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`;
849
+ deps.io.out(response.agent_acknowledgement.text.length > 0
850
+ ? `${recorded}: ${response.agent_acknowledgement.text}`
851
+ : `${recorded}.`);
813
852
  return;
814
853
  }
815
854
  if (response.replies.length > 0) {
816
855
  deps.io.out('Agent Acknowledgement required.');
817
- deps.io.out(`next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`);
856
+ deps.io.out(textRequired
857
+ ? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
858
+ : `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`);
818
859
  return;
819
860
  }
820
861
  deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.');
@@ -969,7 +1010,13 @@ async function uploadImage(deps, client, source) {
969
1010
  return { ok: true, mediaId: grant.media_id };
970
1011
  }
971
1012
  catch (err) {
972
- return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network };
1013
+ // Every other API failure in this file goes through `reportError`, which
1014
+ // maps a revoked credential to the auth code, a server fault to the network
1015
+ // one and everything else to plain failure, and records it in the local
1016
+ // log with whatever next step the server named. This used to answer
1017
+ // `network` to all of them, so an image too large for the account exited
1018
+ // the same way an unreachable server did, and nothing about it was logged.
1019
+ return { ok: false, error: null, exit: reportError(deps, err) };
973
1020
  }
974
1021
  }
975
1022
  // ---------------------------------------------------------------------------
@@ -999,7 +1046,11 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
999
1046
  // would grant slow setup a second budget and let the harness kill us before
1000
1047
  // an accepted answer is journaled or written to stdout.
1001
1048
  const now = deps.now ?? Date.now;
1002
- const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000;
1049
+ // The waiter may spend a long wall clock exactly when no turn is held open
1050
+ // for it, which is the same condition the installer used to declare
1051
+ // `async: true`. One predicate decides it for both.
1052
+ const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform);
1053
+ const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000;
1003
1054
  const logger = log(deps);
1004
1055
  logger.bind({ cmd: `hook ${event}` });
1005
1056
  let started = false;
@@ -1179,18 +1230,8 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
1179
1230
  });
1180
1231
  for (const note of outcome.notes)
1181
1232
  deps.io.err(`notifai: ${note}`);
1182
- if (outcome.stdout !== undefined) {
1183
- let stdout = outcome.stdout;
1184
- if (harness !== undefined &&
1185
- event === 'stop' &&
1186
- HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message') {
1187
- const decision = JSON.parse(outcome.stdout);
1188
- if (decision.decision === 'block' && typeof decision.reason === 'string') {
1189
- stdout = JSON.stringify({ followup_message: decision.reason });
1190
- }
1191
- }
1192
- deps.io.out(stdout);
1193
- }
1233
+ if (outcome.stdout !== undefined)
1234
+ deps.io.out(outcome.stdout);
1194
1235
  return EXIT.ok;
1195
1236
  }
1196
1237
  catch (err) {
@@ -1322,7 +1363,7 @@ export function buildQuestions(flags, question) {
1322
1363
  };
1323
1364
  }
1324
1365
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1325
- return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
1366
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
1326
1367
  }
1327
1368
  const record = parsed;
1328
1369
  const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body');
@@ -1333,7 +1374,7 @@ export function buildQuestions(flags, question) {
1333
1374
  };
1334
1375
  }
1335
1376
  if (!Array.isArray(record['questions'])) {
1336
- return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
1377
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
1337
1378
  }
1338
1379
  const formQuestions = record['questions'];
1339
1380
  if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
@@ -1438,7 +1479,7 @@ function buildAskDraft(config, built, flags, invocation, mediaIds) {
1438
1479
  }
1439
1480
  return { ok: true, draft: result.draft };
1440
1481
  }
1441
- function recordRegisteredQuestion(deps, sessionId, built, draft) {
1482
+ function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
1442
1483
  try {
1443
1484
  registerQuestion(sessionId, deps.env, {
1444
1485
  question: built.questions[0].text,
@@ -1462,6 +1503,28 @@ function recordRegisteredQuestion(deps, sessionId, built, draft) {
1462
1503
  choices: built.questions[0].choices?.length ?? 0,
1463
1504
  media: draft.presentation.media?.length ?? 0,
1464
1505
  });
1506
+ // The block below is the densest guidance this CLI prints, and until now it
1507
+ // was prose only: an agent could not read back the choice ids it must branch
1508
+ // on without asking the server for them. The JSON form carries the same
1509
+ // obligation as data.
1510
+ if (json) {
1511
+ deps.io.out(JSON.stringify({
1512
+ registered: true,
1513
+ questions: built.questions.map((entry) => ({
1514
+ id: entry.id,
1515
+ text: entry.text,
1516
+ ...(entry.choices === undefined ? {} : { choices: entry.choices }),
1517
+ ...(entry.multi === true ? { multi: true } : {}),
1518
+ })),
1519
+ next: {
1520
+ end_turn: true,
1521
+ 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.',
1522
+ route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
1523
+ on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
1524
+ },
1525
+ }, null, 2));
1526
+ return EXIT.ok;
1527
+ }
1465
1528
  for (const [index, entry] of built.questions.entries()) {
1466
1529
  const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1467
1530
  if (entry.choices !== undefined) {
@@ -1504,7 +1567,8 @@ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation)
1504
1567
  }
1505
1568
  const uploaded = await uploadImage(deps, authed.client, image);
1506
1569
  if (!uploaded.ok) {
1507
- deps.io.err(uploaded.error);
1570
+ if (uploaded.error !== null)
1571
+ deps.io.err(uploaded.error);
1508
1572
  return uploaded.exit;
1509
1573
  }
1510
1574
  mediaIds.push(uploaded.mediaId);
@@ -1514,7 +1578,7 @@ async function uploadAskMedia(deps, config, sessionId, built, flags, invocation)
1514
1578
  deps.io.err(ready.error);
1515
1579
  return EXIT.usage;
1516
1580
  }
1517
- return recordRegisteredQuestion(deps, sessionId, built, ready.draft);
1581
+ return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true);
1518
1582
  }
1519
1583
  /**
1520
1584
  * Registers a question for turn-end routing. Returns immediately so the agent
@@ -1615,7 +1679,7 @@ export function askCommand(deps, question, flags) {
1615
1679
  if ((flags.image?.length ?? 0) > 0) {
1616
1680
  return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
1617
1681
  }
1618
- return recordRegisteredQuestion(deps, sessionId, built, preflight.draft);
1682
+ return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true);
1619
1683
  }
1620
1684
  /**
1621
1685
  * Every harness marker present in this environment, in declared order.
@@ -1814,15 +1878,22 @@ function hookActivationAdvice(installations) {
1814
1878
  }
1815
1879
  return `${advice.join('. ')}.`;
1816
1880
  }
1817
- /** Record the one Agent Acknowledgement associated with a replied-to request. */
1881
+ /**
1882
+ * Record the one Agent Acknowledgement associated with a replied-to request.
1883
+ *
1884
+ * `--text` is optional here because the acknowledgement is not: an account may
1885
+ * turn the agent's written reply off, and the receipt must still be recorded so
1886
+ * the user sees that an agent read the answer. The service holds the account's
1887
+ * snapshot, so it — not this process — decides whether text was owed.
1888
+ */
1818
1889
  export async function acknowledgeCommand(deps, requestId, flags) {
1819
1890
  const text = flags.text?.trim() ?? '';
1820
- if (text.length === 0) {
1821
- deps.io.err('--text is required and must contain non-whitespace text.');
1891
+ if (flags.text !== undefined && text.length === 0) {
1892
+ deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.');
1822
1893
  return EXIT.usage;
1823
1894
  }
1824
1895
  if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
1825
- deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`);
1896
+ deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`);
1826
1897
  return EXIT.usage;
1827
1898
  }
1828
1899
  const logger = log(deps);
@@ -1836,7 +1907,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
1836
1907
  if (!authed)
1837
1908
  return EXIT.auth;
1838
1909
  try {
1839
- const result = await authed.client.putAgentAcknowledgement(requestId, { text });
1910
+ const result = await authed.client.putAgentAcknowledgement(requestId, text.length > 0 ? { text } : {});
1840
1911
  const output = {
1841
1912
  request_id: requestId,
1842
1913
  outcome: result.status,
@@ -1876,7 +1947,7 @@ export async function closeCommand(deps, requestId, flags = {}) {
1876
1947
  if (flags.json) {
1877
1948
  deps.io.out(JSON.stringify({
1878
1949
  ...response,
1879
- acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement, response.replies.length > 0),
1950
+ acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
1880
1951
  }, null, 2));
1881
1952
  }
1882
1953
  else {
@@ -1934,7 +2005,7 @@ export function hooksInstallCommand(deps, flags) {
1934
2005
  if (flags.harness === undefined) {
1935
2006
  const detected = detectedHarnesses(deps.cwd, deps.env);
1936
2007
  if (detected.length === 0) {
1937
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
2008
+ deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`);
1938
2009
  return EXIT.usage;
1939
2010
  }
1940
2011
  let ok = true;
@@ -2043,6 +2114,14 @@ export function hooksInstallCommand(deps, flags) {
2043
2114
  for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
2044
2115
  deps.io.out(problem);
2045
2116
  }
2117
+ for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
2118
+ deps.io.out(note);
2119
+ }
2120
+ if (flags.global) {
2121
+ const home = codexHomeNote(deps.env, hookPlatform);
2122
+ if (home !== null)
2123
+ deps.io.out(home);
2124
+ }
2046
2125
  }
2047
2126
  return EXIT.ok;
2048
2127
  }
@@ -2195,12 +2274,6 @@ function assertOwnedRegularFile(file) {
2195
2274
  throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
2196
2275
  }
2197
2276
  }
2198
- const HARNESS_LABEL = {
2199
- 'claude-code': 'Claude Code',
2200
- codex: 'Codex',
2201
- cursor: 'Cursor',
2202
- opencode: 'OpenCode',
2203
- };
2204
2277
  function resolveHarness(deps, requested) {
2205
2278
  if (requested !== undefined) {
2206
2279
  if (HARNESSES.includes(requested))
@@ -2231,7 +2304,7 @@ async function pickHarnessesToInstall(deps, requested) {
2231
2304
  if (deps.io.interactive === true && deps.io.multiselect) {
2232
2305
  const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
2233
2306
  value: name,
2234
- label: HARNESS_LABEL[name],
2307
+ label: HARNESS_LABELS[name],
2235
2308
  ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2236
2309
  })), detected);
2237
2310
  if (picked === null)
@@ -2617,8 +2690,8 @@ export function logsCommand(deps, flags) {
2617
2690
  query.since = since;
2618
2691
  }
2619
2692
  if (flags.level !== undefined) {
2620
- if (!LOG_LEVELS.includes(flags.level)) {
2621
- deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`);
2693
+ if (!RECORD_LEVELS.includes(flags.level)) {
2694
+ deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`);
2622
2695
  return EXIT.usage;
2623
2696
  }
2624
2697
  query.level = flags.level;
@@ -3349,7 +3422,9 @@ async function printInitClose(deps, readiness, flags) {
3349
3422
  ];
3350
3423
  await deps.io.note?.(lines.join('\n'), 'Ready');
3351
3424
  printOptionalLeftovers(deps, leftovers);
3352
- deps.io.out('All set. Agents in this project can notify you and ask questions.');
3425
+ deps.io.out(questions
3426
+ ? 'All set. Agents in this project can notify you and ask you questions.'
3427
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
3353
3428
  await deps.io.outro?.('All set ✨');
3354
3429
  return;
3355
3430
  }
@@ -3364,7 +3439,9 @@ async function printInitClose(deps, readiness, flags) {
3364
3439
  }
3365
3440
  if (blocker === null) {
3366
3441
  printOptionalLeftovers(deps, leftovers);
3367
- deps.io.out('All set. Agents in this project can notify you and ask questions.');
3442
+ deps.io.out(questions
3443
+ ? 'All set. Agents in this project can notify you and ask you questions.'
3444
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
3368
3445
  return;
3369
3446
  }
3370
3447
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
@@ -3893,14 +3970,39 @@ function hookStates(deps) {
3893
3970
  settings,
3894
3971
  ];
3895
3972
  }
3973
+ /**
3974
+ * A human title per check.
3975
+ *
3976
+ * Three checks used to collapse onto "Question routing", so a reader could not
3977
+ * tell which of them had failed, and the rest fell through to their internal
3978
+ * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
3979
+ * thing to branch on; this is only what a person reads.
3980
+ */
3981
+ const CHECK_TITLES = {
3982
+ hooks: 'Question routing',
3983
+ 'hooks (detected)': 'Harnesses detected',
3984
+ 'hooks (active harness)': 'Routing for this harness',
3985
+ 'hooks (active session)': 'Routing for this session',
3986
+ 'hooks (stale)': 'Hook definitions current',
3987
+ 'hooks (adapter)': 'Hook adapter',
3988
+ 'hooks (trust)': 'Codex hook trust',
3989
+ 'hooks (stop shape)': 'Turn-end hook shape',
3990
+ 'hooks (duplicates)': 'Duplicate hook installs',
3991
+ 'hooks (codex representation)': 'Codex hook representation',
3992
+ 'hooks (question admission)': 'Question admission',
3993
+ 'hooks (fired)': 'Hooks have run here',
3994
+ 'hooks (answer continuation)': 'How an answer returns',
3995
+ 'hooks (wake route)': 'Direct wake route',
3996
+ };
3997
+ function checkTitle(name) {
3998
+ return CHECK_TITLES[name] ?? name;
3999
+ }
3896
4000
  /** Real but not in the way; see the note above. */
3897
4001
  const informational = new Set();
3898
4002
  return [
3899
4003
  ...hookChecks(deps).map((check) => ({
3900
4004
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
3901
- title: check.name === 'hooks' || check.name.startsWith('hooks (active')
3902
- ? 'Question routing'
3903
- : check.name,
4005
+ title: checkTitle(check.name),
3904
4006
  status: check.ok
3905
4007
  ? 'ready'
3906
4008
  : check.informational === true || informational.has(check.name)
@@ -3945,7 +4047,7 @@ function stopShapeProblems(installation, platform = process.platform) {
3945
4047
  return [];
3946
4048
  const problems = [];
3947
4049
  for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
3948
- if (installation.harness === 'claude-code' && platform !== 'win32') {
4050
+ if (stopHandlerIsDetached(installation.harness, platform)) {
3949
4051
  if (handler.async !== true) {
3950
4052
  problems.push(`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`);
3951
4053
  }
@@ -3987,7 +4089,7 @@ function hookChecks(deps) {
3987
4089
  name: 'hooks (detected)',
3988
4090
  ok: false,
3989
4091
  informational: true,
3990
- detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
4092
+ detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
3991
4093
  remedy: {
3992
4094
  summary: 'install hooks for every detected harness',
3993
4095
  command: 'notifai hooks install',
@@ -4123,6 +4225,20 @@ function hookChecks(deps) {
4123
4225
  detail: representationProblems.join('; '),
4124
4226
  });
4125
4227
  }
4228
+ else {
4229
+ const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform);
4230
+ if (coexistence.length > 0) {
4231
+ checks.push({
4232
+ name: 'hooks (codex representation)',
4233
+ ok: true,
4234
+ detail: coexistence.join('; '),
4235
+ });
4236
+ }
4237
+ }
4238
+ const codexHome = codexHomeNote(deps.env, deps.hookPlatform);
4239
+ if (codexHome !== null) {
4240
+ checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome });
4241
+ }
4126
4242
  if (active !== null && activeInstallations.length > 0) {
4127
4243
  const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
4128
4244
  checks.push({