@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/src/commands.ts CHANGED
@@ -6,6 +6,7 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
6
6
  import {
7
7
  AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
8
8
  CAPABILITIES_V1,
9
+ PLATFORMS,
9
10
  QUESTION_TEXT_MAX_LENGTH,
10
11
  REPLY_MAX_QUESTIONS,
11
12
  REPLY_MAX_WINDOW_SECONDS,
@@ -32,7 +33,6 @@ import {
32
33
  import {
33
34
  BOOLEAN_CONFIG_KEYS,
34
35
  CONFIG_KEYS,
35
- LOG_LEVELS,
36
36
  NUMERIC_CONFIG_KEYS,
37
37
  configBounds,
38
38
  configDefaultValue,
@@ -66,7 +66,7 @@ import {
66
66
  } from './readiness.js'
67
67
  import type { Tone } from './ui/theme.js'
68
68
  import {
69
- WAITER_CEILING_SECONDS,
69
+ waiterCeilingSeconds,
70
70
  clearAcknowledgementObligation,
71
71
  handleSessionEnd,
72
72
  handleStop,
@@ -82,6 +82,7 @@ import {
82
82
  type EscalationDeliveryRoute,
83
83
  type HookContext,
84
84
  type HookHarness,
85
+ MIN_REPLY_WINDOW_SECONDS,
85
86
  } from './hooks.js'
86
87
  import {
87
88
  LOG_EVENTS,
@@ -95,9 +96,11 @@ import {
95
96
  renderRecord,
96
97
  type LogQuery,
97
98
  type Logger,
99
+ RECORD_LEVELS,
98
100
  } from './logging.js'
99
101
  import {
100
102
  BLOCKING_STOP_TIMEOUT_SECONDS,
103
+ stopHandlerIsDetached,
101
104
  CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
102
105
  HARNESSES,
103
106
  applyPlan,
@@ -105,6 +108,8 @@ import {
105
108
  buildHookConfig,
106
109
  codexLayerDir,
107
110
  codexLayerPaths,
111
+ codexCoexistenceNotes,
112
+ codexHomeNote,
108
113
  codexRepresentationProblems,
109
114
  codexTrustProblems,
110
115
  codexProjectRoot,
@@ -319,7 +324,9 @@ function reportError(
319
324
  if (paths.length > 0) deps.io.err(`the server rejected: ${paths.join(', ')}`)
320
325
  // A 422 on a request this CLI built is not a user error: the two sides
321
326
  // disagree about the contract, in whichever direction. `doctor` says which.
322
- if (err.status === 422) {
327
+ // Codes that name a policy the account chose are the exception — the
328
+ // contract held, the body simply did not satisfy that account.
329
+ if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
323
330
  deps.io.err(
324
331
  'this build sent a field the server did not accept — the CLI and server ' +
325
332
  'disagree about the contract; check with `notifai doctor`',
@@ -565,6 +572,13 @@ export async function capabilitiesCommand(
565
572
  deps: CommandDeps,
566
573
  flags: { json?: boolean; platform?: Platform },
567
574
  ): Promise<number> {
575
+ // Locally, and with the same message `send` gives. Spending a round trip to
576
+ // have the server answer "Request validation failed" told the caller neither
577
+ // which flag was wrong nor what it accepts.
578
+ if (flags.platform !== undefined && !(PLATFORMS as readonly string[]).includes(flags.platform)) {
579
+ deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`)
580
+ return EXIT.usage
581
+ }
568
582
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
569
583
  const credential = deps.store.load()
570
584
  const baseUrl = resolvedBaseUrl(config, credential)
@@ -638,6 +652,17 @@ export async function sendCommand(
638
652
  deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
639
653
  return EXIT.usage
640
654
  }
655
+ // Kind now decides the sound a notification arrives with, so it is asked for
656
+ // at the boundary rather than defaulted silently: an unlabelled send would
657
+ // reach the user as ordinary news whatever actually happened.
658
+ if (!flags.reply && flags.kind === undefined) {
659
+ deps.io.err(
660
+ '--kind is required: say what this notification is. ' +
661
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
662
+ '--reply makes it a question without --kind.',
663
+ )
664
+ return EXIT.usage
665
+ }
641
666
  if (!flags.reply && flags.kind === 'question') {
642
667
  deps.io.err('--kind question requires --reply so the question can be answered.')
643
668
  return EXIT.usage
@@ -676,10 +701,12 @@ export async function sendCommand(
676
701
  flags.reply &&
677
702
  flags.replyWindow !== undefined &&
678
703
  (!Number.isInteger(flags.replyWindow) ||
679
- flags.replyWindow < 60 ||
704
+ flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
680
705
  flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
681
706
  ) {
682
- deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
707
+ deps.io.err(
708
+ `--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`,
709
+ )
683
710
  return EXIT.usage
684
711
  }
685
712
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
@@ -711,7 +738,7 @@ export async function sendCommand(
711
738
  }
712
739
  const uploaded = await uploadImage(deps, authed.client, image)
713
740
  if (!uploaded.ok) {
714
- deps.io.err(uploaded.error)
741
+ if (uploaded.error !== null) deps.io.err(uploaded.error)
715
742
  return uploaded.exit
716
743
  }
717
744
  mediaIds.push(uploaded.mediaId)
@@ -794,6 +821,7 @@ export async function sendCommand(
794
821
  request_id: receipt.request_id,
795
822
  replies: [],
796
823
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
824
+ agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
797
825
  agent_acknowledgement: null,
798
826
  acknowledgement_command: null,
799
827
  degraded: false,
@@ -935,7 +963,14 @@ export async function repliesCommand(
935
963
  .map((entry) => entry.request_id)
936
964
  .filter((id): id is string => id !== undefined)
937
965
  if (requestIds.length === 0) {
938
- deps.io.err(`Session ${sessionId} has no pushed question pending.`)
966
+ // `--json` is a promise about stdout, and an empty result is still a
967
+ // result: a caller that asked for machine-readable output must not have
968
+ // to parse an English sentence to learn there was nothing pending.
969
+ if (flags.json === true) {
970
+ deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2))
971
+ } else {
972
+ deps.io.err(`Session ${sessionId} has no pushed question pending.`)
973
+ }
939
974
  return EXIT.noReply
940
975
  }
941
976
  }
@@ -1071,6 +1106,7 @@ export async function waitForReply(
1071
1106
  request_id: requestId,
1072
1107
  reply_expires_at: null,
1073
1108
  agent_acknowledgement_required: false,
1109
+ agent_acknowledgement_text_required: false,
1074
1110
  agent_acknowledgement: null,
1075
1111
  replies: [],
1076
1112
  } satisfies ListRepliesResponse),
@@ -1113,14 +1149,21 @@ function isNonNegativeInteger(value: number): boolean {
1113
1149
  return Number.isInteger(value) && value >= 0
1114
1150
  }
1115
1151
 
1152
+ function acknowledgeInvocation(requestId: string, textRequired: boolean): string {
1153
+ return textRequired
1154
+ ? `notifai acknowledge ${requestId} --text <text>`
1155
+ : `notifai acknowledge ${requestId}`
1156
+ }
1157
+
1116
1158
  function acknowledgementCommand(
1117
1159
  requestId: string,
1118
1160
  required: boolean,
1161
+ textRequired: boolean,
1119
1162
  acknowledgement: ListRepliesResponse['agent_acknowledgement'],
1120
1163
  hasReply = true,
1121
1164
  ): string | null {
1122
1165
  return required && acknowledgement === null && hasReply
1123
- ? `notifai acknowledge ${requestId} --text <text>`
1166
+ ? acknowledgeInvocation(requestId, textRequired)
1124
1167
  : null
1125
1168
  }
1126
1169
 
@@ -1132,9 +1175,11 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
1132
1175
  replies: response.replies,
1133
1176
  agent_acknowledgement_required: response.agent_acknowledgement_required,
1134
1177
  agent_acknowledgement: response.agent_acknowledgement,
1178
+ agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
1135
1179
  acknowledgement_command: acknowledgementCommand(
1136
1180
  response.request_id,
1137
1181
  response.agent_acknowledgement_required,
1182
+ response.agent_acknowledgement_text_required,
1138
1183
  response.agent_acknowledgement,
1139
1184
  response.replies.length > 0,
1140
1185
  ),
@@ -1147,16 +1192,22 @@ function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResp
1147
1192
  deps.io.out('Agent Acknowledgement: not required for this request.')
1148
1193
  return
1149
1194
  }
1195
+ const textRequired = response.agent_acknowledgement_text_required
1150
1196
  if (response.agent_acknowledgement !== null) {
1197
+ const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`
1151
1198
  deps.io.out(
1152
- `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`,
1199
+ response.agent_acknowledgement.text.length > 0
1200
+ ? `${recorded}: ${response.agent_acknowledgement.text}`
1201
+ : `${recorded}.`,
1153
1202
  )
1154
1203
  return
1155
1204
  }
1156
1205
  if (response.replies.length > 0) {
1157
1206
  deps.io.out('Agent Acknowledgement required.')
1158
1207
  deps.io.out(
1159
- `next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`,
1208
+ textRequired
1209
+ ? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
1210
+ : `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`,
1160
1211
  )
1161
1212
  return
1162
1213
  }
@@ -1296,7 +1347,8 @@ const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
1296
1347
 
1297
1348
  type UploadResult =
1298
1349
  | { ok: true; mediaId: string }
1299
- | { ok: false; error: string; exit: number }
1350
+ /** `error: null` means `reportError` already said it; do not print it twice. */
1351
+ | { ok: false; error: string | null; exit: number }
1300
1352
 
1301
1353
  /** `--image` accepts a media id, a local file path, or an http(s) URL. */
1302
1354
  async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
@@ -1331,7 +1383,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
1331
1383
  await client.uploadMedia(grant, bytes)
1332
1384
  return { ok: true, mediaId: grant.media_id }
1333
1385
  } catch (err) {
1334
- return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network }
1386
+ // Every other API failure in this file goes through `reportError`, which
1387
+ // maps a revoked credential to the auth code, a server fault to the network
1388
+ // one and everything else to plain failure, and records it in the local
1389
+ // log with whatever next step the server named. This used to answer
1390
+ // `network` to all of them, so an image too large for the account exited
1391
+ // the same way an unreachable server did, and nothing about it was logged.
1392
+ return { ok: false, error: null, exit: reportError(deps, err) }
1335
1393
  }
1336
1394
  }
1337
1395
 
@@ -1374,7 +1432,11 @@ export async function hookRunCommand(
1374
1432
  // would grant slow setup a second budget and let the harness kill us before
1375
1433
  // an accepted answer is journaled or written to stdout.
1376
1434
  const now = deps.now ?? Date.now
1377
- const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000
1435
+ // The waiter may spend a long wall clock exactly when no turn is held open
1436
+ // for it, which is the same condition the installer used to declare
1437
+ // `async: true`. One predicate decides it for both.
1438
+ const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform)
1439
+ const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000
1378
1440
 
1379
1441
  const logger = log(deps)
1380
1442
  logger.bind({ cmd: `hook ${event}` })
@@ -1567,20 +1629,7 @@ export async function hookRunCommand(
1567
1629
  ...outcome.log,
1568
1630
  })
1569
1631
  for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
1570
- if (outcome.stdout !== undefined) {
1571
- let stdout = outcome.stdout
1572
- if (
1573
- harness !== undefined &&
1574
- event === 'stop' &&
1575
- HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
1576
- ) {
1577
- const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
1578
- if (decision.decision === 'block' && typeof decision.reason === 'string') {
1579
- stdout = JSON.stringify({ followup_message: decision.reason })
1580
- }
1581
- }
1582
- deps.io.out(stdout)
1583
- }
1632
+ if (outcome.stdout !== undefined) deps.io.out(outcome.stdout)
1584
1633
  return EXIT.ok
1585
1634
  } catch (err) {
1586
1635
  // SessionEnd defers its start record until after cleanup; if cleanup itself
@@ -1700,6 +1749,8 @@ function rejectedPaths(details: unknown): string[] {
1700
1749
  }
1701
1750
 
1702
1751
  export interface AskFlags {
1752
+ /** Emit the registration and its turn obligation as one JSON object. */
1753
+ json?: boolean
1703
1754
  choice?: string[]
1704
1755
  /** The single question is multi-select: several answers may be chosen. */
1705
1756
  multi?: boolean
@@ -1749,7 +1800,7 @@ export function buildQuestions(
1749
1800
  }
1750
1801
  }
1751
1802
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1752
- return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1803
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
1753
1804
  }
1754
1805
  const record = parsed as Record<string, unknown>
1755
1806
  const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
@@ -1760,7 +1811,7 @@ export function buildQuestions(
1760
1811
  }
1761
1812
  }
1762
1813
  if (!Array.isArray(record['questions'])) {
1763
- return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1814
+ return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
1764
1815
  }
1765
1816
  const formQuestions = record['questions']
1766
1817
  if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
@@ -1886,6 +1937,7 @@ function recordRegisteredQuestion(
1886
1937
  sessionId: string,
1887
1938
  built: BuiltQuestions,
1888
1939
  draft: NotificationDraftT,
1940
+ json = false,
1889
1941
  ): number {
1890
1942
  try {
1891
1943
  registerQuestion(
@@ -1914,6 +1966,37 @@ function recordRegisteredQuestion(
1914
1966
  choices: built.questions[0]!.choices?.length ?? 0,
1915
1967
  media: draft.presentation.media?.length ?? 0,
1916
1968
  })
1969
+ // The block below is the densest guidance this CLI prints, and until now it
1970
+ // was prose only: an agent could not read back the choice ids it must branch
1971
+ // on without asking the server for them. The JSON form carries the same
1972
+ // obligation as data.
1973
+ if (json) {
1974
+ deps.io.out(
1975
+ JSON.stringify(
1976
+ {
1977
+ registered: true,
1978
+ questions: built.questions.map((entry) => ({
1979
+ id: entry.id,
1980
+ text: entry.text,
1981
+ ...(entry.choices === undefined ? {} : { choices: entry.choices }),
1982
+ ...(entry.multi === true ? { multi: true } : {}),
1983
+ })),
1984
+ next: {
1985
+ end_turn: true,
1986
+ in_this_turn:
1987
+ 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
1988
+ route_neutral:
1989
+ 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
1990
+ on_answer:
1991
+ 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
1992
+ },
1993
+ },
1994
+ null,
1995
+ 2,
1996
+ ),
1997
+ )
1998
+ return EXIT.ok
1999
+ }
1917
2000
  for (const [index, entry] of built.questions.entries()) {
1918
2001
  const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1919
2002
  if (entry.choices !== undefined) {
@@ -1973,7 +2056,7 @@ async function uploadAskMedia(
1973
2056
  }
1974
2057
  const uploaded = await uploadImage(deps, authed.client, image)
1975
2058
  if (!uploaded.ok) {
1976
- deps.io.err(uploaded.error)
2059
+ if (uploaded.error !== null) deps.io.err(uploaded.error)
1977
2060
  return uploaded.exit
1978
2061
  }
1979
2062
  mediaIds.push(uploaded.mediaId)
@@ -1983,7 +2066,7 @@ async function uploadAskMedia(
1983
2066
  deps.io.err(ready.error)
1984
2067
  return EXIT.usage
1985
2068
  }
1986
- return recordRegisteredQuestion(deps, sessionId, built, ready.draft)
2069
+ return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
1987
2070
  }
1988
2071
 
1989
2072
  /**
@@ -2121,7 +2204,7 @@ export function askCommand(
2121
2204
  source.invocation,
2122
2205
  )
2123
2206
  }
2124
- return recordRegisteredQuestion(deps, sessionId, built, preflight.draft)
2207
+ return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true)
2125
2208
  }
2126
2209
 
2127
2210
  /**
@@ -2399,20 +2482,27 @@ function hookActivationAdvice(installations: Installation[]): string {
2399
2482
  return `${advice.join('. ')}.`
2400
2483
  }
2401
2484
 
2402
- /** Record the one Agent Acknowledgement associated with a replied-to request. */
2485
+ /**
2486
+ * Record the one Agent Acknowledgement associated with a replied-to request.
2487
+ *
2488
+ * `--text` is optional here because the acknowledgement is not: an account may
2489
+ * turn the agent's written reply off, and the receipt must still be recorded so
2490
+ * the user sees that an agent read the answer. The service holds the account's
2491
+ * snapshot, so it — not this process — decides whether text was owed.
2492
+ */
2403
2493
  export async function acknowledgeCommand(
2404
2494
  deps: CommandDeps,
2405
2495
  requestId: string,
2406
2496
  flags: { text?: string; json?: boolean },
2407
2497
  ): Promise<number> {
2408
2498
  const text = flags.text?.trim() ?? ''
2409
- if (text.length === 0) {
2410
- deps.io.err('--text is required and must contain non-whitespace text.')
2499
+ if (flags.text !== undefined && text.length === 0) {
2500
+ deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.')
2411
2501
  return EXIT.usage
2412
2502
  }
2413
2503
  if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
2414
2504
  deps.io.err(
2415
- `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`,
2505
+ `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`,
2416
2506
  )
2417
2507
  return EXIT.usage
2418
2508
  }
@@ -2427,7 +2517,10 @@ export async function acknowledgeCommand(
2427
2517
  const authed = authedClient(deps, config)
2428
2518
  if (!authed) return EXIT.auth
2429
2519
  try {
2430
- const result = await authed.client.putAgentAcknowledgement(requestId, { text })
2520
+ const result = await authed.client.putAgentAcknowledgement(
2521
+ requestId,
2522
+ text.length > 0 ? { text } : {},
2523
+ )
2431
2524
  const output = {
2432
2525
  request_id: requestId,
2433
2526
  outcome: result.status,
@@ -2476,6 +2569,7 @@ export async function closeCommand(
2476
2569
  acknowledgement_command: acknowledgementCommand(
2477
2570
  response.request_id,
2478
2571
  response.agent_acknowledgement_required,
2572
+ response.agent_acknowledgement_text_required,
2479
2573
  response.agent_acknowledgement,
2480
2574
  response.replies.length > 0,
2481
2575
  ),
@@ -2554,7 +2648,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2554
2648
  if (flags.harness === undefined) {
2555
2649
  const detected = detectedHarnesses(deps.cwd, deps.env)
2556
2650
  if (detected.length === 0) {
2557
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
2651
+ deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
2558
2652
  return EXIT.usage
2559
2653
  }
2560
2654
  let ok = true
@@ -2679,6 +2773,13 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2679
2773
  for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
2680
2774
  deps.io.out(problem)
2681
2775
  }
2776
+ for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
2777
+ deps.io.out(note)
2778
+ }
2779
+ if (flags.global) {
2780
+ const home = codexHomeNote(deps.env, hookPlatform)
2781
+ if (home !== null) deps.io.out(home)
2782
+ }
2682
2783
  }
2683
2784
  return EXIT.ok
2684
2785
  }
@@ -2844,13 +2945,6 @@ function assertOwnedRegularFile(file: string): void {
2844
2945
  }
2845
2946
  }
2846
2947
 
2847
- const HARNESS_LABEL: Record<Harness, string> = {
2848
- 'claude-code': 'Claude Code',
2849
- codex: 'Codex',
2850
- cursor: 'Cursor',
2851
- opencode: 'OpenCode',
2852
- }
2853
-
2854
2948
  function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
2855
2949
  if (requested !== undefined) {
2856
2950
  if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
@@ -2887,7 +2981,7 @@ async function pickHarnessesToInstall(
2887
2981
  'Which agent harnesses should Notifai wire here?',
2888
2982
  HARNESSES.map((name) => ({
2889
2983
  value: name,
2890
- label: HARNESS_LABEL[name],
2984
+ label: HARNESS_LABELS[name],
2891
2985
  ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2892
2986
  })),
2893
2987
  detected,
@@ -3360,8 +3454,8 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
3360
3454
  query.since = since
3361
3455
  }
3362
3456
  if (flags.level !== undefined) {
3363
- if (!(LOG_LEVELS as readonly string[]).includes(flags.level)) {
3364
- deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`)
3457
+ if (!(RECORD_LEVELS as readonly string[]).includes(flags.level)) {
3458
+ deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`)
3365
3459
  return EXIT.usage
3366
3460
  }
3367
3461
  query.level = flags.level as LogLevel
@@ -4243,7 +4337,11 @@ async function printInitClose(
4243
4337
  ]
4244
4338
  await deps.io.note?.(lines.join('\n'), 'Ready')
4245
4339
  printOptionalLeftovers(deps, leftovers)
4246
- deps.io.out('All set. Agents in this project can notify you and ask questions.')
4340
+ deps.io.out(
4341
+ questions
4342
+ ? 'All set. Agents in this project can notify you and ask you questions.'
4343
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
4344
+ )
4247
4345
  await deps.io.outro?.('All set ✨')
4248
4346
  return
4249
4347
  }
@@ -4259,7 +4357,11 @@ async function printInitClose(
4259
4357
 
4260
4358
  if (blocker === null) {
4261
4359
  printOptionalLeftovers(deps, leftovers)
4262
- deps.io.out('All set. Agents in this project can notify you and ask questions.')
4360
+ deps.io.out(
4361
+ questions
4362
+ ? 'All set. Agents in this project can notify you and ask you questions.'
4363
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
4364
+ )
4263
4365
  return
4264
4366
  }
4265
4367
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
@@ -4845,15 +4947,41 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
4845
4947
  ]
4846
4948
  }
4847
4949
 
4950
+ /**
4951
+ * A human title per check.
4952
+ *
4953
+ * Three checks used to collapse onto "Question routing", so a reader could not
4954
+ * tell which of them had failed, and the rest fell through to their internal
4955
+ * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
4956
+ * thing to branch on; this is only what a person reads.
4957
+ */
4958
+ const CHECK_TITLES: Readonly<Record<string, string>> = {
4959
+ hooks: 'Question routing',
4960
+ 'hooks (detected)': 'Harnesses detected',
4961
+ 'hooks (active harness)': 'Routing for this harness',
4962
+ 'hooks (active session)': 'Routing for this session',
4963
+ 'hooks (stale)': 'Hook definitions current',
4964
+ 'hooks (adapter)': 'Hook adapter',
4965
+ 'hooks (trust)': 'Codex hook trust',
4966
+ 'hooks (stop shape)': 'Turn-end hook shape',
4967
+ 'hooks (duplicates)': 'Duplicate hook installs',
4968
+ 'hooks (codex representation)': 'Codex hook representation',
4969
+ 'hooks (question admission)': 'Question admission',
4970
+ 'hooks (fired)': 'Hooks have run here',
4971
+ 'hooks (answer continuation)': 'How an answer returns',
4972
+ 'hooks (wake route)': 'Direct wake route',
4973
+ }
4974
+
4975
+ function checkTitle(name: string): string {
4976
+ return CHECK_TITLES[name] ?? name
4977
+ }
4978
+
4848
4979
  /** Real but not in the way; see the note above. */
4849
4980
  const informational = new Set<string>()
4850
4981
  return [
4851
4982
  ...hookChecks(deps).map((check) => ({
4852
4983
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
4853
- title:
4854
- check.name === 'hooks' || check.name.startsWith('hooks (active')
4855
- ? 'Question routing'
4856
- : check.name,
4984
+ title: checkTitle(check.name),
4857
4985
  status: check.ok
4858
4986
  ? 'ready' as const
4859
4987
  : check.informational === true || informational.has(check.name)
@@ -4903,7 +5031,7 @@ function stopShapeProblems(
4903
5031
  for (const handler of installation.handlers.filter(
4904
5032
  (entry) => handlerEvent(entry.command) === 'stop',
4905
5033
  )) {
4906
- if (installation.harness === 'claude-code' && platform !== 'win32') {
5034
+ if (stopHandlerIsDetached(installation.harness, platform)) {
4907
5035
  if (handler.async !== true) {
4908
5036
  problems.push(
4909
5037
  `${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
@@ -4964,7 +5092,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4964
5092
  name: 'hooks (detected)',
4965
5093
  ok: false,
4966
5094
  informational: true,
4967
- detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
5095
+ detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
4968
5096
  remedy: {
4969
5097
  summary: 'install hooks for every detected harness',
4970
5098
  command: 'notifai hooks install',
@@ -5140,6 +5268,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
5140
5268
  ok: false,
5141
5269
  detail: representationProblems.join('; '),
5142
5270
  })
5271
+ } else {
5272
+ const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform)
5273
+ if (coexistence.length > 0) {
5274
+ checks.push({
5275
+ name: 'hooks (codex representation)',
5276
+ ok: true,
5277
+ detail: coexistence.join('; '),
5278
+ })
5279
+ }
5280
+ }
5281
+
5282
+ const codexHome = codexHomeNote(deps.env, deps.hookPlatform)
5283
+ if (codexHome !== null) {
5284
+ checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome })
5143
5285
  }
5144
5286
 
5145
5287
  if (active !== null && activeInstallations.length > 0) {
@@ -86,7 +86,7 @@ describe('formatValue', () => {
86
86
  it('explains what an unset value means instead of printing null', () => {
87
87
  // `sound = null` is accurate and tells the reader nothing about what will
88
88
  // happen; the whole complaint about the old output in one line.
89
- expect(formatValue('sound', null)).toBe('not specified; the destination uses its normal behavior')
89
+ expect(formatValue('sound', null)).toBe('not set; each kind brings its own sound')
90
90
  expect(formatValue('devices', null)).toBe('every device that can receive')
91
91
  })
92
92
 
@@ -85,7 +85,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
85
85
  kind: 'boolean',
86
86
  summary: 'Whether a registered question may reach your phone and Mac at all',
87
87
  detail:
88
- 'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever the presence settings say. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
88
+ 'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
89
89
  example: 'true',
90
90
  },
91
91
  ask_grace_seconds: {
@@ -99,6 +99,17 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
99
99
  example: '0',
100
100
  },
101
101
 
102
+ reply_window_seconds: {
103
+ label: 'How long an answer is accepted',
104
+ group: 'questions',
105
+ kind: 'integer',
106
+ unit: 's',
107
+ summary: 'How long the service keeps accepting your answer to a question',
108
+ detail:
109
+ 'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
110
+ example: '86400',
111
+ },
112
+
102
113
  sound: {
103
114
  label: 'Sound',
104
115
  group: 'delivery',
@@ -113,8 +124,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
113
124
  },
114
125
  summary: 'Set the sound notifications from here use',
115
126
  detail:
116
- 'Setting this pins one sound for every notification sent from here. Kind remains semantic status metadata and never changes native-banner attention.\n\nLeave this unset to omit the sound field and let the destination use its normal behavior. A common explicit preference is `none`, to make a noisy project completely silent.',
117
- unsetMeans: 'not specified; the destination uses its normal behavior',
127
+ 'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and the standard tone for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
128
+ unsetMeans: 'not set; each kind brings its own sound',
118
129
  },
119
130
  interruption_level: {
120
131
  label: 'Interruption level',
@@ -124,11 +135,11 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
124
135
  choiceHints: {
125
136
  passive: 'no sound or wake — waits in the notification list',
126
137
  active: 'wakes the screen and plays the sound',
127
- time_sensitive: 'may break through Focus modes',
138
+ time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
128
139
  },
129
140
  summary: 'Set how insistently notifications from here arrive',
130
141
  detail:
131
- 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind never changes it.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` can pierce a Focus mode, so reserve it for cases that genuinely earn it.',
142
+ 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
132
143
  unsetMeans: 'not specified; the destination uses its normal behavior',
133
144
  },
134
145
  devices: {