@raidiant/notifai 0.2.1 → 0.4.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 (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
package/src/commands.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto'
2
2
  import { spawn } from 'node:child_process'
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
4
4
  import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
@@ -24,14 +24,17 @@ import {
24
24
  ApiCallError,
25
25
  NetworkError,
26
26
  createClient,
27
+ isRetryableReplyPollError,
27
28
  type ApiClient,
28
29
  type ClientOptions,
29
30
  } from './client.js'
30
31
  import {
31
32
  BOOLEAN_CONFIG_KEYS,
32
33
  CONFIG_KEYS,
34
+ LOG_LEVELS,
33
35
  NUMERIC_CONFIG_KEYS,
34
36
  configBounds,
37
+ configDefaultValue,
35
38
  findProjectConfigPath,
36
39
  findProjectLocalConfigPath,
37
40
  globalConfigPath,
@@ -40,24 +43,53 @@ import {
40
43
  type CliConfig,
41
44
  type ConfigKey,
42
45
  type FlagOverrides,
46
+ type LogLevel,
43
47
  } from './config.js'
48
+ import { acceptedValues, configInfo } from './config-schema.js'
49
+ import { atomicWriteFileSync } from './atomic-file.js'
50
+ import { withTargetFileLock } from './file-lock.js'
51
+ import {
52
+ renderConfigExplain,
53
+ renderConfigList,
54
+ renderConfigPlain,
55
+ } from './ui/config-view.js'
44
56
  import type { CredentialStore, MachineCredential } from './credentials.js'
45
- import { firstBlocker, openItems, type Readiness, type ReadinessState } from './readiness.js'
57
+ import {
58
+ firstBlocker,
59
+ openItems,
60
+ type Readiness,
61
+ type ReadinessState,
62
+ type StateStatus,
63
+ } from './readiness.js'
64
+ import type { Tone } from './ui/theme.js'
46
65
  import {
47
66
  handleSessionEnd,
48
67
  handleStop,
49
68
  handleUserPromptSubmit,
50
69
  parseHookInput,
51
70
  pruneAbandonedSessions,
71
+ readMatchingProjectSessionPointer,
52
72
  readProjectSession,
53
73
  readProjectSessionPointer,
54
74
  readSessionState,
55
75
  registerQuestion,
56
76
  type HookContext,
57
- type HookEnvelope,
58
77
  type HookHarness,
59
78
  } from './hooks.js'
60
79
  import { readIdleSeconds } from './idle.js'
80
+ import {
81
+ LOG_EVENTS,
82
+ activeLogPath,
83
+ archiveLogPaths,
84
+ logConfigResolved,
85
+ logSettingsFrom,
86
+ logsDiskUsage,
87
+ nullLogger,
88
+ readLogRecords,
89
+ renderRecord,
90
+ type LogQuery,
91
+ type Logger,
92
+ } from './logging.js'
61
93
  import {
62
94
  HARNESSES,
63
95
  applyPlan,
@@ -65,6 +97,7 @@ import {
65
97
  buildCursorHookConfig,
66
98
  buildHookConfig,
67
99
  codexLayerDir,
100
+ codexTrustProblems,
68
101
  codexProjectRoot,
69
102
  detectHarness,
70
103
  findInstallations,
@@ -79,6 +112,11 @@ import {
79
112
  type Harness,
80
113
  type Installation,
81
114
  } from './install-hooks.js'
115
+ import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js'
116
+ import {
117
+ inspectHookAdapter,
118
+ installHookAdapter,
119
+ } from './hook-adapter.js'
82
120
  import {
83
121
  isOurOpencodePlugin,
84
122
  opencodePluginSource,
@@ -113,7 +151,13 @@ export interface CommandIo {
113
151
  outro?(message: string): Promise<void>
114
152
  note?(message: string, title?: string): Promise<void>
115
153
  spinner?(message: string): Promise<CommandSpinner | null>
116
- check?(ok: boolean, message: string): Promise<void>
154
+ /**
155
+ * A report line. `tone` distinguishes the two states that are neither pass
156
+ * nor fail — something optional the user declined, and something that was
157
+ * never evaluated — which a boolean has to round to one or the other.
158
+ * Optional so existing implementations keep type-checking.
159
+ */
160
+ check?(ok: boolean, message: string, tone?: Tone): Promise<void>
117
161
  }
118
162
 
119
163
  export interface CommandSpinner {
@@ -127,6 +171,8 @@ export interface CommandDeps {
127
171
  store: CredentialStore
128
172
  env: NodeJS.ProcessEnv
129
173
  cwd: string
174
+ /** Test seam; production fixes the hook adapter under os.homedir(). */
175
+ hookAdapterHome?: string
130
176
  /** Test seam; production uses fetch against base_url. */
131
177
  clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
132
178
  /** Test seam for bounded polling without wall-clock sleeps. */
@@ -137,6 +183,28 @@ export interface CommandDeps {
137
183
  idleSeconds?: () => number | null
138
184
  /** Test seam and production adapter for the external native skills installer. */
139
185
  nativeSkills?: NativeSkills
186
+ /**
187
+ * The local record of what this invocation did. Optional so a test fake need
188
+ * not carry one; `log(deps)` supplies a logger that records nothing.
189
+ */
190
+ logger?: Logger
191
+ }
192
+
193
+ /** The invocation's logger, or one that discards everything. */
194
+ export function log(deps: CommandDeps): Logger {
195
+ return deps.logger ?? nullLogger()
196
+ }
197
+
198
+ function loadLoggedConfig(
199
+ deps: CommandDeps,
200
+ options: Parameters<typeof loadConfig>[0],
201
+ ): CliConfig {
202
+ const config = loadConfig(options)
203
+ const logger = log(deps)
204
+ logger.adopt(logSettingsFrom(config))
205
+ logger.bind({ project: config.project.value })
206
+ logConfigResolved(logger, config)
207
+ return config
140
208
  }
141
209
 
142
210
  export const EXIT = {
@@ -154,7 +222,7 @@ function makeClient(
154
222
  bearer: string | null,
155
223
  options?: ClientOptions,
156
224
  ): ApiClient {
157
- return (deps.clientFactory ?? createClient)(baseUrl, bearer, options)
225
+ return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) })
158
226
  }
159
227
 
160
228
  function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
@@ -164,6 +232,10 @@ function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null
164
232
  function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
165
233
  const credential = deps.store.load()
166
234
  if (!credential) {
235
+ // The commonest reason a command does nothing, and one that leaves no other
236
+ // trace: it returns before any request is made, so without this the log
237
+ // shows an exit code and no cause.
238
+ log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() })
167
239
  deps.io.err('Not signed in. Run `notifai login` first.')
168
240
  return null
169
241
  }
@@ -174,7 +246,22 @@ function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient
174
246
  }
175
247
  }
176
248
 
177
- function reportError(deps: CommandDeps, err: unknown): number {
249
+ function reportError(
250
+ deps: CommandDeps,
251
+ err: unknown,
252
+ context: Record<string, unknown> = {},
253
+ ): number {
254
+ // Recorded before it is printed: stderr from an agent's shell call is often
255
+ // the one thing that does not survive into the next turn, and this is exactly
256
+ // the line someone comes back looking for.
257
+ log(deps).error('cli.error', {
258
+ ...context,
259
+ ...(err instanceof ApiCallError
260
+ ? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
261
+ : err instanceof NetworkError
262
+ ? { kind: 'network', message: err.message }
263
+ : { kind: 'unknown', message: String(err) }),
264
+ })
178
265
  if (err instanceof ApiCallError) {
179
266
  deps.io.err(`${err.code}: ${err.message}`)
180
267
  if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
@@ -197,7 +284,7 @@ export async function loginCommand(
197
284
  deps: CommandDeps,
198
285
  flags: { name?: string; baseUrl?: string; open?: boolean },
199
286
  ): Promise<number> {
200
- const config = loadConfig({ cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
287
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
201
288
  const baseUrl = config.base_url.value
202
289
  const machineName = flags.name ?? os.hostname()
203
290
  const secret = randomBytes(32).toString('base64url')
@@ -326,7 +413,7 @@ export async function accessStatusCommand(
326
413
  deps: CommandDeps,
327
414
  flags: { json?: boolean },
328
415
  ): Promise<number> {
329
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
416
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
330
417
  const authed = authedClient(deps, config)
331
418
  if (!authed) return EXIT.auth
332
419
  try {
@@ -354,7 +441,7 @@ export async function accessStatusCommand(
354
441
  // ---------------------------------------------------------------------------
355
442
 
356
443
  export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
357
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
444
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
358
445
  const authed = authedClient(deps, config)
359
446
  if (!authed) return EXIT.auth
360
447
  try {
@@ -387,11 +474,37 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
387
474
  }
388
475
  }
389
476
 
477
+ /**
478
+ * Devices as data, for surfaces that render them themselves.
479
+ *
480
+ * `devicesCommand` prints and returns an exit code, which is the right shape
481
+ * for a command and the wrong one for a menu that wants to show names beside
482
+ * checkboxes. Silent on every failure by design: the interactive app already
483
+ * shows the credential and connectivity state on its status card, so a second
484
+ * error line here would be reporting the same fault twice.
485
+ */
486
+ export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
487
+ if (!deps.store.load()) return null
488
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
489
+ const authed = authedClient(deps, config)
490
+ if (!authed) return null
491
+ try {
492
+ return (await authed.client.listDevices()).devices
493
+ } catch {
494
+ return null
495
+ }
496
+ }
497
+
498
+ /** Whether a device is in a state where a notification would actually arrive. */
499
+ export function canDeviceReceive(device: RoutableDevice): boolean {
500
+ return deviceCanReceive(device)
501
+ }
502
+
390
503
  export async function capabilitiesCommand(
391
504
  deps: CommandDeps,
392
505
  flags: { json?: boolean; platform?: Platform },
393
506
  ): Promise<number> {
394
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
507
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
395
508
  const credential = deps.store.load()
396
509
  const baseUrl = resolvedBaseUrl(config, credential)
397
510
  const client = makeClient(deps, baseUrl, null)
@@ -474,7 +587,7 @@ export async function sendCommand(
474
587
  deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
475
588
  return EXIT.usage
476
589
  }
477
- const config = loadConfig({
590
+ const config = loadLoggedConfig(deps, {
478
591
  cwd: deps.cwd,
479
592
  env: deps.env,
480
593
  flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
@@ -515,38 +628,71 @@ export async function sendCommand(
515
628
  }
516
629
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
517
630
  const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
631
+ let receipt: SubmissionReceipt
518
632
  try {
519
- const receipt = await authed.client.submit(
633
+ receipt = await authed.client.submit(
520
634
  { idempotency_key: idempotencyKey, draft: build.draft },
521
635
  waitSeconds,
522
636
  )
523
- const receiptExit = receiptExitCode(receipt)
524
- if (!flags.json) deps.io.out(formatReceipt(receipt))
525
- else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
637
+ } catch (err) {
638
+ return reportError(deps, err)
639
+ }
640
+ const receiptExit = receiptExitCode(receipt)
641
+ // The single most useful line in the log: it ties the local invocation to
642
+ // the server-side request id, which is what every later question about this
643
+ // notification ("did it arrive?", "which device?") is asked in terms of.
644
+ log(deps).info('send.submitted', {
645
+ request_id: receipt.request_id,
646
+ kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
647
+ title: flags.title,
648
+ overall: receipt.overall,
649
+ replayed: receipt.replayed,
650
+ deliveries: receipt.deliveries.length,
651
+ wait_seconds: waitSeconds,
652
+ exit: receiptExit,
653
+ })
654
+ log(deps).info('send.outcome', {
655
+ request_id: receipt.request_id,
656
+ overall: receipt.overall,
657
+ // Per device, because "it said accepted but nothing arrived" is answered
658
+ // by which device reached which state and why the provider said so.
659
+ devices: receipt.deliveries.map((delivery) => ({
660
+ device: delivery.device_name,
661
+ state: delivery.state,
662
+ attempts: delivery.attempts,
663
+ provider_status: delivery.provider_status,
664
+ provider_reason: delivery.provider_reason,
665
+ })),
666
+ ...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
667
+ })
668
+ if (!flags.json) deps.io.out(formatReceipt(receipt))
669
+ else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
526
670
 
527
- // A zero wait can no longer reach here: --reply guarantees a positive one.
528
- if (!flags.reply || receiptExit !== EXIT.ok) {
529
- if (flags.json) {
530
- deps.io.out(
531
- flags.reply
532
- ? JSON.stringify({
533
- type: 'reply_result',
534
- request_id: receipt.request_id,
535
- replies: [],
536
- degraded: false,
537
- })
538
- : JSON.stringify(receipt, null, 2),
539
- )
540
- }
541
- return receiptExit
671
+ // A zero wait can no longer reach here: --reply guarantees a positive one.
672
+ if (!flags.reply || receiptExit !== EXIT.ok) {
673
+ if (flags.json) {
674
+ deps.io.out(
675
+ flags.reply
676
+ ? JSON.stringify({
677
+ type: 'reply_result',
678
+ request_id: receipt.request_id,
679
+ replies: [],
680
+ degraded: false,
681
+ })
682
+ : JSON.stringify(receipt, null, 2),
683
+ )
542
684
  }
685
+ return receiptExit
686
+ }
543
687
 
688
+ try {
544
689
  const result = await waitForReply(authed.client, receipt.request_id, {
545
690
  timeoutSeconds: replyTimeout,
546
691
  afterSeq: 0,
547
692
  now: deps.now,
548
693
  sleep: deps.sleep,
549
694
  })
695
+ recordReplies(deps, receipt.request_id, result.response.replies)
550
696
  if (flags.json) {
551
697
  deps.io.out(
552
698
  JSON.stringify({
@@ -559,17 +705,48 @@ export async function sendCommand(
559
705
  } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
560
706
  else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
561
707
  if (result.degraded) {
562
- deps.io.err(DEGRADED_WAIT_WARNING)
708
+ log(deps).error('cli.error', {
709
+ kind: 'network',
710
+ operation: 'reply_wait',
711
+ request_id: receipt.request_id,
712
+ degraded: true,
713
+ message: 'the reply wait ended while the server was unreachable or faulting',
714
+ })
715
+ deps.io.err(degradedWaitWarning(receipt.request_id))
563
716
  return EXIT.network
564
717
  }
565
718
  if (result.timedOut) {
566
719
  deps.io.err(
567
720
  `No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
568
- `\`notifai close ${receipt.request_id}\`.`,
721
+ `\`notifai close ${receipt.request_id}\`. ` +
722
+ `This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`,
569
723
  )
570
724
  }
571
725
  return result.timedOut ? EXIT.noReply : EXIT.ok
572
726
  } catch (err) {
727
+ // Receipt already printed; a wait fault must not read as "send failed".
728
+ // Permanent poll errors (auth, closed window, not found) still surface, but
729
+ // always name the durable request and point at recovery.
730
+ if (err instanceof ApiCallError || err instanceof NetworkError) {
731
+ log(deps).error('cli.error', {
732
+ kind: err instanceof ApiCallError ? 'api' : 'network',
733
+ operation: 'reply_wait',
734
+ request_id: receipt.request_id,
735
+ ...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
736
+ message: err.message,
737
+ })
738
+ deps.io.err(
739
+ `notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
740
+ `Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
741
+ )
742
+ if (err instanceof ApiCallError) {
743
+ if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
744
+ return err.status >= 500 || err.status === 429 || err.status === 408
745
+ ? EXIT.network
746
+ : EXIT.failed
747
+ }
748
+ return EXIT.network
749
+ }
573
750
  return reportError(deps, err)
574
751
  }
575
752
  }
@@ -660,40 +837,49 @@ export async function repliesCommand(
660
837
  return EXIT.usage
661
838
  }
662
839
 
663
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
840
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
664
841
  const authed = authedClient(deps, config)
665
842
  if (!authed) return EXIT.auth
666
843
  try {
667
844
  let anyReplies = false
668
- let anyDegraded = false
845
+ const degradedRequestIds: string[] = []
669
846
  let allTimedOut = true
670
847
  const jsonBodies: object[] = []
671
848
  for (const requestId of requestIds) {
672
- const result = await waitForReply(authed.client, requestId, {
673
- timeoutSeconds: waitSeconds,
674
- afterSeq,
675
- now: deps.now,
676
- sleep: deps.sleep,
677
- })
678
- if (flags.json) {
679
- jsonBodies.push({ ...result.response, degraded: result.degraded })
680
- } else if (result.response.replies.length > 0) {
681
- if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
682
- printReplies(deps, result.response.replies)
683
- } else {
684
- printNoReply(deps, requestId, result.response.reply_expires_at)
849
+ try {
850
+ const result = await waitForReply(authed.client, requestId, {
851
+ timeoutSeconds: waitSeconds,
852
+ afterSeq,
853
+ now: deps.now,
854
+ sleep: deps.sleep,
855
+ })
856
+ recordReplies(deps, requestId, result.response.replies)
857
+ if (flags.json) {
858
+ jsonBodies.push({ ...result.response, degraded: result.degraded })
859
+ } else if (result.response.replies.length > 0) {
860
+ if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
861
+ printReplies(deps, result.response.replies)
862
+ } else {
863
+ printNoReply(deps, requestId, result.response.reply_expires_at)
864
+ }
865
+ anyReplies ||= result.response.replies.length > 0
866
+ if (result.degraded) degradedRequestIds.push(requestId)
867
+ allTimedOut &&= result.timedOut
868
+ } catch (err) {
869
+ recordDegradedReplyWaits(deps, degradedRequestIds)
870
+ return reportError(deps, err, { operation: 'reply_wait', request_id: requestId })
685
871
  }
686
- anyReplies ||= result.response.replies.length > 0
687
- anyDegraded ||= result.degraded
688
- allTimedOut &&= result.timedOut
689
872
  }
690
873
  if (flags.json) {
691
874
  // One request keeps the response shape agents already parse; several
692
875
  // (only possible via --pending) arrive as an array in queue order.
693
876
  deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
694
877
  }
695
- if (anyDegraded) {
696
- deps.io.err(DEGRADED_WAIT_WARNING)
878
+ if (degradedRequestIds.length > 0) {
879
+ recordDegradedReplyWaits(deps, degradedRequestIds)
880
+ // Name a request whose polls actually degraded, not merely the first item
881
+ // in a multi-request queue that may have completed cleanly.
882
+ deps.io.err(degradedWaitWarning(degradedRequestIds[0]!))
697
883
  return EXIT.network
698
884
  }
699
885
  if (anyReplies) return EXIT.ok
@@ -730,8 +916,8 @@ export async function waitForReply(
730
916
  const sleep = options.sleep ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)))
731
917
  const deadline = now() + options.timeoutSeconds * 1000
732
918
  let lastResponse: ListRepliesResponse | null = null
733
- let lastNetworkError: NetworkError | null = null
734
- let consecutiveNetworkErrors = 0
919
+ let lastTransientError: Error | null = null
920
+ let consecutiveTransientErrors = 0
735
921
  let firstPoll = true
736
922
 
737
923
  while (firstPoll || now() < deadline) {
@@ -744,24 +930,31 @@ export async function waitForReply(
744
930
  afterSeq: options.afterSeq,
745
931
  })
746
932
  lastResponse = response
747
- lastNetworkError = null
748
- consecutiveNetworkErrors = 0
933
+ lastTransientError = null
934
+ consecutiveTransientErrors = 0
749
935
  if (response.replies.length > 0) return { response, timedOut: false, degraded: false }
750
936
 
751
937
  const pauseMs = Math.min(250, Math.max(0, deadline - now()))
752
938
  if (pauseMs > 0) await sleep(pauseMs)
753
939
  } catch (err) {
754
- if (!(err instanceof NetworkError)) throw err
755
- lastNetworkError = err
756
- consecutiveNetworkErrors += 1
940
+ if (!isRetryableReplyPollError(err)) throw err
941
+ lastTransientError = err instanceof Error ? err : new Error(String(err))
942
+ consecutiveTransientErrors += 1
757
943
  const remainingAfterError = Math.max(0, deadline - now())
758
944
  if (remainingAfterError === 0) break
759
- const backoffMs = Math.min(250 * 2 ** (consecutiveNetworkErrors - 1), 2_000, remainingAfterError)
945
+ const backoffMs = Math.min(
946
+ 250 * 2 ** (consecutiveTransientErrors - 1),
947
+ 2_000,
948
+ remainingAfterError,
949
+ )
760
950
  await sleep(backoffMs)
761
951
  }
762
952
  }
763
953
 
764
- if (!lastResponse && lastNetworkError) throw lastNetworkError
954
+ // Never saw a successful poll: surface the fault so callers get EXIT.network
955
+ // rather than a fake empty silence. The durable request is still on the
956
+ // server; the typed recovery is `notifai replies <id>`.
957
+ if (!lastResponse && lastTransientError) throw lastTransientError
765
958
  return {
766
959
  response:
767
960
  lastResponse ??
@@ -770,23 +963,55 @@ export async function waitForReply(
770
963
  // A poll succeeded at some point, so we do not throw — but the last thing
771
964
  // we know is that we could not reach the server. Reporting that as a plain
772
965
  // "no reply" would let an agent treat an unseen refusal as consent.
773
- degraded: lastNetworkError !== null,
966
+ degraded: lastTransientError !== null,
774
967
  }
775
968
  }
776
969
 
970
+ function recordDegradedReplyWaits(deps: CommandDeps, requestIds: readonly string[]): void {
971
+ if (requestIds.length === 0) return
972
+ log(deps).error('cli.error', {
973
+ kind: 'network',
974
+ operation: 'reply_wait',
975
+ request_ids: requestIds,
976
+ degraded: true,
977
+ message: 'the reply wait ended while the server was unreachable or faulting',
978
+ })
979
+ }
980
+
777
981
  /**
778
982
  * Shared by every surface that waits: "the user did not answer" and "I could
779
983
  * not find out" must not look the same, because agents branch on the exit code
780
984
  * and one of those two branches is safe to proceed from.
985
+ *
986
+ * Delivery / Companion Receipt / OS presentation are separate facts — a wait
987
+ * fault is not evidence that the push failed.
781
988
  */
782
- const DEGRADED_WAIT_WARNING =
783
- 'notifai: the wait ended during a network outage, so this is "could not find out", ' +
784
- 'not "no answer" — the reply may already be waiting. Retry with `notifai replies <id>`.'
785
-
989
+ function degradedWaitWarning(requestId: string): string {
990
+ return (
991
+ `notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
992
+ `so this is "could not find out", not "no answer" — the request is still durable and the ` +
993
+ `reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
994
+ `Retry with \`notifai replies ${requestId}\`.`
995
+ )
996
+ }
786
997
  function isNonNegativeInteger(value: number): boolean {
787
998
  return Number.isInteger(value) && value >= 0
788
999
  }
789
1000
 
1001
+ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
1002
+ const logger = log(deps)
1003
+ for (const reply of replies) {
1004
+ logger.info('reply.received', {
1005
+ request_id: requestId,
1006
+ sequence: reply.seq,
1007
+ device: reply.device_name,
1008
+ text: reply.text,
1009
+ answers: reply.answers,
1010
+ source: reply.source,
1011
+ })
1012
+ }
1013
+ }
1014
+
790
1015
  function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
791
1016
  for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
792
1017
  const contradiction = contradictingAnswer(replies)
@@ -835,7 +1060,7 @@ export async function statusCommand(
835
1060
  requestId: string,
836
1061
  flags: { json?: boolean },
837
1062
  ): Promise<number> {
838
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1063
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
839
1064
  const authed = authedClient(deps, config)
840
1065
  if (!authed) return EXIT.auth
841
1066
  try {
@@ -845,6 +1070,7 @@ export async function statusCommand(
845
1070
  return EXIT.ok
846
1071
  }
847
1072
  deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
1073
+ let anyReplyReceived = false
848
1074
  for (const d of snapshot.deliveries) {
849
1075
  deps.io.out(` ${d.device_name}:`)
850
1076
  deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
@@ -862,10 +1088,27 @@ export async function statusCommand(
862
1088
  " Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
863
1089
  )
864
1090
  }
1091
+ // Notifai never learns whether the OS painted a banner; saying so stops
1092
+ // a reply-wait fault from being misread as "the phone never showed it".
1093
+ deps.io.out(
1094
+ ' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown',
1095
+ )
1096
+ const replyEvent = d.events.find((e) => e.stage === 'reply_received')
1097
+ if (replyEvent) {
1098
+ anyReplyReceived = true
1099
+ deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`)
1100
+ } else {
1101
+ deps.io.out(' Reply received: not yet recorded on this delivery')
1102
+ }
865
1103
  for (const e of d.events) {
866
1104
  deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
867
1105
  }
868
1106
  }
1107
+ deps.io.out(
1108
+ anyReplyReceived
1109
+ ? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
1110
+ : ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``,
1111
+ )
869
1112
  return EXIT.ok
870
1113
  } catch (err) {
871
1114
  return reportError(deps, err)
@@ -934,6 +1177,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
934
1177
  export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'] as const
935
1178
  export type HookEvent = (typeof HOOK_EVENTS)[number]
936
1179
 
1180
+ /** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
1181
+ export function hookDefersDiagnosticsUntilAfterCleanup(
1182
+ event: unknown,
1183
+ ): event is 'session-end' {
1184
+ return event === 'session-end'
1185
+ }
1186
+
937
1187
  /**
938
1188
  * Runs one harness hook. Contract with every harness: hook JSON arrives on
939
1189
  * stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
@@ -953,53 +1203,129 @@ export async function hookRunCommand(
953
1203
  deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
954
1204
  return EXIT.usage
955
1205
  }
956
- let envelope: HookEnvelope
1206
+
1207
+ // One clock owns the complete Stop invocation, including stdin, config,
1208
+ // credentials, and client construction. Starting this inside `handleStop`
1209
+ // would grant slow setup a second budget and let the harness kill us before
1210
+ // an accepted answer is journaled or written to stdout.
1211
+ const now = deps.now ?? Date.now
1212
+ const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000
1213
+
1214
+ const logger = log(deps)
1215
+ logger.bind({ cmd: `hook ${event}` })
1216
+ let started = false
1217
+ const start = (data: Record<string, unknown> = {}): void => {
1218
+ if (started) return
1219
+ started = true
1220
+ logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data })
1221
+ }
1222
+ const failureData = (err: unknown): Record<string, unknown> =>
1223
+ err instanceof ApiCallError
1224
+ ? { status: err.status, code: err.code, message: err.message, details: err.details }
1225
+ : { message: err instanceof Error ? err.message : String(err) }
1226
+
1227
+ let raw: string
957
1228
  try {
958
- const raw = await readStdin()
959
- if (raw.trim() !== '') {
960
- try {
961
- const parsed: unknown = JSON.parse(raw)
962
- if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
963
- } catch {
964
- deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
965
- return EXIT.ok
966
- }
1229
+ raw = await readStdin()
1230
+ } catch (err) {
1231
+ start({ input: 'unavailable' })
1232
+ logger.error('hook.end', {
1233
+ hook: event,
1234
+ outcome: 'ignored',
1235
+ reason: 'input-read-failed',
1236
+ ...failureData(err),
1237
+ })
1238
+ return EXIT.ok
1239
+ }
1240
+
1241
+ if (raw.trim() !== '') {
1242
+ try {
1243
+ const parsed: unknown = JSON.parse(raw)
1244
+ if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
1245
+ } catch {
1246
+ start({ input: 'malformed' })
1247
+ logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' })
1248
+ deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
1249
+ return EXIT.ok
967
1250
  }
968
- envelope = parseHookInput(raw)
969
- if (harness === 'cursor') {
970
- const sessionId = envelope.session_id ?? envelope.conversation_id
971
- const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
972
- envelope = {
973
- ...envelope,
974
- ...(sessionId === undefined ? {} : { session_id: sessionId }),
975
- ...(cwd === undefined ? {} : { cwd }),
976
- stop_hook_active:
977
- envelope.stop_hook_active ??
978
- (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
979
- }
1251
+ }
1252
+
1253
+ let envelope = parseHookInput(raw)
1254
+ if (harness === 'cursor') {
1255
+ const sessionId = envelope.session_id ?? envelope.conversation_id
1256
+ const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
1257
+ envelope = {
1258
+ ...envelope,
1259
+ ...(sessionId === undefined ? {} : { session_id: sessionId }),
1260
+ ...(cwd === undefined ? {} : { cwd }),
1261
+ stop_hook_active:
1262
+ envelope.stop_hook_active ??
1263
+ (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
1264
+ }
1265
+ }
1266
+
1267
+ const cwd = envelope.cwd ?? deps.cwd
1268
+ const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
1269
+ logger.bind({ session: envelope.session_id ?? null })
1270
+ let config: CliConfig | null = null
1271
+ let configFailure: unknown
1272
+ try {
1273
+ config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
1274
+ // The hook's project is the session's, not this process's, and the log
1275
+ // settings that apply are that project's too. Keeping a mutable bootstrap
1276
+ // logger lets this more-specific layer turn logging back on.
1277
+ logger.adopt(logSettingsFrom(config))
1278
+ logger.bind({ project: config.project.value })
1279
+ if (!sessionEnd) {
1280
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
1281
+ logConfigResolved(logger, config)
1282
+ }
1283
+ } catch (err) {
1284
+ configFailure = err
1285
+ if (!sessionEnd) {
1286
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
1287
+ logger.error('hook.end', {
1288
+ hook: event,
1289
+ outcome: 'failed',
1290
+ reason: 'config-failed',
1291
+ ...failureData(err),
1292
+ })
1293
+ for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
1294
+ return EXIT.ok
980
1295
  }
981
- } catch {
982
- return EXIT.ok
983
1296
  }
984
1297
 
985
- // Everything below is inside one fail-open boundary. Config parsing,
986
- // credential loading and client construction can all throw, and a hook that
987
- // exits non-zero makes the harness report a failure — strictly worse for the
988
- // user than not having installed the hook at all.
1298
+ // Everything below is inside one fail-open boundary. Credential loading,
1299
+ // client construction and hook handling can all throw, and a hook that exits
1300
+ // non-zero makes the harness report a failure — strictly worse than skipping.
989
1301
  try {
990
- if (event === 'session-end') {
1302
+ if (sessionEnd) {
1303
+ // Codex gives SessionEnd one second total. Do every durable cleanup write
1304
+ // before lifecycle diagnostics: the log lock is deliberately allowed to
1305
+ // wait that long, and a busy log must never preserve ended-session state
1306
+ // or its inherited configuration. The resolved config above is retained
1307
+ // in memory so logging still uses the ending session's settings afterwards.
991
1308
  const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
1309
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
1310
+ if (config !== null) logConfigResolved(logger, config)
1311
+ const data = { hook: event, decided: false, ...outcome.log }
1312
+ if (configFailure === undefined) logger.info('hook.end', data)
1313
+ else {
1314
+ logger.error('hook.end', {
1315
+ ...data,
1316
+ reason: 'config-failed',
1317
+ config_error: failureData(configFailure),
1318
+ })
1319
+ }
992
1320
  for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
993
1321
  return EXIT.ok
994
1322
  }
995
1323
 
996
- // Resolve config against the session's project rather than our own working
997
- // directory. `cwd` is in the payload precisely because which project a
998
- // session belongs to is the harness's business, not ours.
999
- const cwd = envelope.cwd ?? deps.cwd
1000
- const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
1324
+ // Non-SessionEnd hooks cannot reach here without resolved configuration.
1325
+ const resolved = config!
1001
1326
  const credential = deps.store.load()
1002
1327
  if (!credential) {
1328
+ logger.error('hook.end', { hook: event, outcome: 'not-paired' })
1003
1329
  deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
1004
1330
  return EXIT.ok
1005
1331
  }
@@ -1007,9 +1333,9 @@ export async function hookRunCommand(
1007
1333
  // repository can commit `.notifai/config.toml`, and honouring a base_url
1008
1334
  // from it would hand this machine's bearer token to whatever host it names.
1009
1335
  const baseUrl = credential.baseUrl
1010
- if (config.base_url.source !== 'default' && config.base_url.value !== baseUrl) {
1336
+ if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
1011
1337
  deps.io.err(
1012
- `notifai: ignoring base_url from ${config.base_url.source}; hooks only talk to ${baseUrl}`,
1338
+ `notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`,
1013
1339
  )
1014
1340
  }
1015
1341
  // UserPromptSubmit runs in front of the user's own prompt under a 15s
@@ -1019,12 +1345,20 @@ export async function hookRunCommand(
1019
1345
  deps,
1020
1346
  baseUrl,
1021
1347
  `Bearer nfm_${credential.machineId}.${credential.secret}`,
1022
- { timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000 },
1348
+ {
1349
+ timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
1350
+ ...(event === 'stop'
1351
+ ? {
1352
+ deadlineAt:
1353
+ processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
1354
+ now,
1355
+ }
1356
+ : {}),
1357
+ },
1023
1358
  )
1024
- const now = deps.now ?? Date.now
1025
1359
  const ctx: HookContext = {
1026
1360
  client,
1027
- config,
1361
+ config: resolved,
1028
1362
  env: deps.env,
1029
1363
  now,
1030
1364
  idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
@@ -1042,24 +1376,41 @@ export async function hookRunCommand(
1042
1376
  degraded: result.degraded,
1043
1377
  }
1044
1378
  },
1379
+ log: logger,
1045
1380
  ...(harness === undefined ? {} : { harness }),
1046
1381
  }
1047
1382
 
1048
1383
  // Real clock, deliberately, not `deps.now`. This compares against file
1049
1384
  // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
1050
1385
  // would have it delete live session state as "abandoned".
1051
- // Rate-limited to once a day by its own stamp file, so the common cost is
1052
- // one stat on a hook that sits on the critical path of every turn.
1053
- pruneAbandonedSessions(deps.env)
1386
+ // Daily state pruning is housekeeping, not part of the Stop delivery
1387
+ // contract. Its directory scan has no useful bound, so keep it on the
1388
+ // short prompt path and never spend the answer owner's finite budget on it.
1389
+ if (event !== 'stop') pruneAbandonedSessions(deps.env)
1054
1390
 
1055
1391
  const outcome =
1056
1392
  event === 'user-prompt-submit'
1057
1393
  ? await handleUserPromptSubmit(ctx, envelope)
1058
- : await handleStop(ctx, envelope)
1394
+ : await handleStop(ctx, envelope, processDeadlineAt)
1395
+ // Answer diagnostics are already persisted once as hook.answer. Keep every
1396
+ // other note in the lifecycle record without duplicating the user's text.
1397
+ const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
1398
+ logger.info('hook.end', {
1399
+ hook: event,
1400
+ // A hook that returns stdout has taken over the turn; one that does not
1401
+ // has handed the terminal back. That distinction is the whole contract.
1402
+ decided: outcome.stdout !== undefined,
1403
+ ...(notes.length === 0 ? {} : { notes }),
1404
+ ...outcome.log,
1405
+ })
1059
1406
  for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
1060
1407
  if (outcome.stdout !== undefined) {
1061
1408
  let stdout = outcome.stdout
1062
- if (harness === 'cursor' && event === 'stop') {
1409
+ if (
1410
+ harness !== undefined &&
1411
+ event === 'stop' &&
1412
+ HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
1413
+ ) {
1063
1414
  const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
1064
1415
  if (decision.decision === 'block' && typeof decision.reason === 'string') {
1065
1416
  stdout = JSON.stringify({ followup_message: decision.reason })
@@ -1069,6 +1420,17 @@ export async function hookRunCommand(
1069
1420
  }
1070
1421
  return EXIT.ok
1071
1422
  } catch (err) {
1423
+ // SessionEnd defers its start record until after cleanup; if cleanup itself
1424
+ // fails, begin the after-the-fact lifecycle here before recording why.
1425
+ if (sessionEnd) start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
1426
+ // The hook still exits 0 — handing the terminal back is always right. What
1427
+ // this adds is that the reason survives, including the server's own words.
1428
+ logger.error('hook.end', {
1429
+ hook: event,
1430
+ outcome: 'failed',
1431
+ reason: 'execution-failed',
1432
+ ...failureData(err),
1433
+ })
1072
1434
  for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
1073
1435
  return EXIT.ok
1074
1436
  }
@@ -1227,27 +1589,40 @@ function buildOneQuestion(
1227
1589
 
1228
1590
  /**
1229
1591
  * Registers a question for turn-end routing under the user's presence config.
1230
- * Returns immediately so the agent can ask in prose and end its turn. With the
1231
- * default presence gate, recent keyboard or mouse activity keeps it local;
1232
- * `require_idle = false` intentionally permits a push while the user is active.
1592
+ * Returns immediately so the agent can ask in prose and end its turn. Presence
1593
+ * does not block the shipped default; `require_idle = true` intentionally keeps
1594
+ * the question local while the user is active.
1233
1595
  */
1234
1596
  export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
1597
+ // Validate before route discovery. A malformed question belongs to the
1598
+ // caller and should not be hidden behind whichever harness setup issue
1599
+ // happens to exist on this machine.
1600
+ const built = buildQuestions(flags, question)
1601
+ if (!built.ok) {
1602
+ deps.io.err(built.error)
1603
+ return EXIT.usage
1604
+ }
1605
+ const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
1606
+ if (!routingConfig.ask_notifications.value) {
1607
+ deps.io.err(
1608
+ 'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
1609
+ )
1610
+ return EXIT.usage
1611
+ }
1612
+ if (deps.store.load() === null) {
1613
+ deps.io.err(
1614
+ 'Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.',
1615
+ )
1616
+ return EXIT.usage
1617
+ }
1235
1618
  // An agent calling this gets no hook payload. Harness-native environment
1236
- // markers identify the active owner, while the UserPromptSubmit hook leaves
1237
- // a pointer keyed on the project directory with the hook's canonical id.
1238
- // The pointer outranks the
1239
- // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
1240
- // label rather than the harness's own id, and the hooks key state by the
1241
- // latter — the env var is only trusted when no hook has spoken.
1619
+ // markers identify the active owner, while UserPromptSubmit adds the hook's
1620
+ // canonical id to the directory's concurrent-session index.
1242
1621
  const now = (deps.now ?? Date.now)()
1243
- const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now)
1244
- const pointer = projectPointer?.sessionId ?? null
1245
1622
  const active = activeHarnessSession(deps.env)
1246
1623
  let sessionId: string | undefined
1247
- if (flags.session !== undefined) {
1248
- sessionId = flags.session
1249
- } else if (active !== null) {
1250
- const installations = findInstallations(deps.cwd, deps.env)
1624
+ if (active !== null) {
1625
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
1251
1626
  const activeInstalled = installations.some(
1252
1627
  (installation) => installation.harness === active.harness,
1253
1628
  )
@@ -1257,35 +1632,52 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1257
1632
  }
1258
1633
  return EXIT.usage
1259
1634
  }
1635
+ if (active.sessionId === undefined) {
1636
+ for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
1637
+ deps.io.err(`Question routing is not ready: ${problem}`)
1638
+ }
1639
+ return EXIT.usage
1640
+ }
1641
+ const projectPointer = readMatchingProjectSessionPointer(
1642
+ deps.cwd,
1643
+ deps.env,
1644
+ now,
1645
+ active.sessionId,
1646
+ active.harness,
1647
+ )
1260
1648
  if (projectPointer === null) {
1261
1649
  for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1262
1650
  deps.io.err(line)
1263
1651
  }
1264
1652
  return EXIT.usage
1265
1653
  }
1266
- if (
1267
- projectPointer.harness !== active.harness ||
1268
- (active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)
1269
- ) {
1270
- for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
1271
- deps.io.err(line)
1272
- }
1654
+ if (flags.session !== undefined && flags.session !== active.sessionId) {
1655
+ deps.io.err(
1656
+ `Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
1657
+ )
1658
+ return EXIT.usage
1659
+ }
1660
+ const routeProblems = activeQuestionRouteProblems(deps, active, installations)
1661
+ if (routeProblems.length > 0) {
1662
+ for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
1663
+ return EXIT.usage
1664
+ }
1665
+ if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
1666
+ deps.io.err(
1667
+ `Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`,
1668
+ )
1273
1669
  return EXIT.usage
1274
1670
  }
1275
1671
  sessionId = projectPointer.sessionId
1276
1672
  } else {
1277
- sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
1278
- }
1279
- if (!sessionId) {
1280
1673
  for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1674
+ deps.io.err(
1675
+ 'Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.',
1676
+ )
1281
1677
  return EXIT.usage
1282
1678
  }
1283
- // Validate here, not at push time. The push happens inside a hook, where a
1284
- // rejection becomes a stderr note the agent never reads — so a malformed
1285
- // question set would look like it registered fine and then silently never ask.
1286
- const built = buildQuestions(flags, question)
1287
- if (!built.ok) {
1288
- deps.io.err(built.error)
1679
+ if (!sessionId) {
1680
+ for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1289
1681
  return EXIT.usage
1290
1682
  }
1291
1683
  try {
@@ -1300,9 +1692,20 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1300
1692
  (deps.now ?? Date.now)(),
1301
1693
  )
1302
1694
  } catch (err) {
1695
+ log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
1303
1696
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
1304
1697
  return EXIT.failed
1305
1698
  }
1699
+ // `ask` returns immediately and the push happens later inside a hook, so this
1700
+ // is the only local evidence that the question was ever registered — the
1701
+ // starting point for "the agent asked, and nothing ever reached my phone".
1702
+ log(deps).info('ask.registered', {
1703
+ ok: true,
1704
+ session: sessionId,
1705
+ questions: built.questions.length,
1706
+ text: built.questions[0]!.text,
1707
+ choices: built.questions[0]!.choices?.length ?? 0,
1708
+ })
1306
1709
  for (const [index, entry] of built.questions.entries()) {
1307
1710
  const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1308
1711
  if (entry.choices !== undefined) {
@@ -1359,6 +1762,69 @@ function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | nu
1359
1762
  return null
1360
1763
  }
1361
1764
 
1765
+ /**
1766
+ * One fail-closed admission gate shared by every active harness route.
1767
+ * Finding a file is not readiness: the exact session, one current definition,
1768
+ * the stable adapter, a long enough Stop owner, and (for Codex) trust must all
1769
+ * be true before `ask` is allowed to create an answerable notification.
1770
+ */
1771
+ function activeQuestionRouteProblems(
1772
+ deps: CommandDeps,
1773
+ active: ActiveHarnessSession,
1774
+ installations: Installation[],
1775
+ ): string[] {
1776
+ const problems: string[] = []
1777
+ if (active.sessionId === undefined) {
1778
+ problems.push(
1779
+ `the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
1780
+ )
1781
+ }
1782
+ const capability = HARNESS_CAPABILITIES[active.harness]
1783
+ if (capability.stopContinuation === 'unsupported') {
1784
+ problems.push(`${active.label}: ${capability.deliveryContract}`)
1785
+ }
1786
+ const matching = installations.filter(
1787
+ (installation) => installation.harness === active.harness,
1788
+ )
1789
+ if (matching.length > 1) {
1790
+ problems.push(
1791
+ `${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`,
1792
+ )
1793
+ }
1794
+ for (const installation of matching) {
1795
+ for (const problem of installation.problems ?? []) {
1796
+ problems.push(`${installation.file}: ${problem}`)
1797
+ }
1798
+ for (const handler of installation.handlers) {
1799
+ const event = handlerEvent(handler.command)
1800
+ if (event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)) {
1801
+ problems.push(
1802
+ `${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`,
1803
+ )
1804
+ }
1805
+ }
1806
+ }
1807
+ for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
1808
+ problems.push(problem)
1809
+ }
1810
+ if (active.harness !== 'codex') {
1811
+ const required = blockingHookTimeoutSeconds()
1812
+ for (const installation of matching) {
1813
+ for (const handler of installation.handlers.filter(
1814
+ (entry) => handlerEvent(entry.command) === 'stop',
1815
+ )) {
1816
+ if (handler.timeout === undefined || handler.timeout < required) {
1817
+ problems.push(
1818
+ `${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the live answer owner requires ${required}s`,
1819
+ )
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+ problems.push(...codexTrustProblems(matching, deps.env))
1825
+ return [...new Set(problems)]
1826
+ }
1827
+
1362
1828
  type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
1363
1829
 
1364
1830
  function diagnoseActiveHarnessSession(
@@ -1403,7 +1869,7 @@ function diagnoseActiveHarnessSession(
1403
1869
  * checked after a prompt before assuming that a new session is required.
1404
1870
  */
1405
1871
  function diagnoseMissingSession(deps: CommandDeps): string[] {
1406
- const installations = findInstallations(deps.cwd, deps.env)
1872
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
1407
1873
  if (installations.length === 0) {
1408
1874
  return [
1409
1875
  'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
@@ -1415,7 +1881,7 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
1415
1881
  `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
1416
1882
  'but no usable session pointer from the last 24 hours exists here.',
1417
1883
  hookActivationAdvice(installations),
1418
- 'To ask from this session anyway, pass --session <id>.',
1884
+ 'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
1419
1885
  ]
1420
1886
  }
1421
1887
 
@@ -1460,7 +1926,7 @@ function hookActivationAdvice(installations: Installation[]): string {
1460
1926
  }
1461
1927
  if (harnesses.has('opencode')) {
1462
1928
  advice.push(
1463
- 'OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt',
1929
+ 'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
1464
1930
  )
1465
1931
  }
1466
1932
  return `${advice.join('. ')}.`
@@ -1468,7 +1934,7 @@ function hookActivationAdvice(installations: Installation[]): string {
1468
1934
 
1469
1935
  /** Retire a question so a late answer is rejected rather than silently lost. */
1470
1936
  export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
1471
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1937
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1472
1938
  const authed = authedClient(deps, config)
1473
1939
  if (!authed) return EXIT.auth
1474
1940
  try {
@@ -1493,43 +1959,41 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1493
1959
  if (!harness) return EXIT.usage
1494
1960
  const execPath = flags.execPath ?? process.execPath
1495
1961
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
1496
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1962
+ let adapterPath: string
1963
+ try {
1964
+ adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path
1965
+ } catch (err) {
1966
+ deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
1967
+ return EXIT.failed
1968
+ }
1969
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1497
1970
  const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
1498
1971
 
1499
1972
  // OpenCode's adapter is a generated plugin module rather than a handler
1500
1973
  // merged into a settings document, so it owns the whole file.
1501
1974
  if (harness === 'opencode') {
1502
1975
  return installOpencodePlugin(deps, file, {
1503
- execPath,
1504
- scriptPath,
1505
- timeoutSeconds: blockingHookTimeoutSeconds(
1506
- config.ask_grace_seconds.value,
1507
- config.hook_reply_timeout_seconds.value,
1508
- ),
1976
+ adapterPath,
1977
+ timeoutSeconds: blockingHookTimeoutSeconds(),
1509
1978
  })
1510
1979
  }
1511
1980
 
1512
1981
  if (harness === 'cursor') {
1513
- let document
1982
+ let merged: ReturnType<typeof mergeCursorHooks>
1514
1983
  try {
1515
- document = loadCursorSettings(file)
1516
- } catch (err) {
1517
- deps.io.err(String(err))
1518
- return EXIT.failed
1519
- }
1520
- const merged = mergeCursorHooks(
1521
- document,
1522
- buildCursorHookConfig({
1523
- execPath,
1524
- scriptPath,
1525
- replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1526
- graceSeconds: config.ask_grace_seconds.value,
1527
- harness: 'cursor',
1528
- }),
1529
- scriptPath,
1530
- )
1531
- try {
1532
- applyPlan(file, merged.document)
1984
+ merged = withTargetFileLock(file, () => {
1985
+ const document = loadCursorSettings(file)
1986
+ const result = mergeCursorHooks(
1987
+ document,
1988
+ buildCursorHookConfig({
1989
+ adapterPath,
1990
+ harness: 'cursor',
1991
+ }),
1992
+ scriptPath,
1993
+ )
1994
+ applyPlan(file, result.document)
1995
+ return result
1996
+ })
1533
1997
  } catch (err) {
1534
1998
  deps.io.err(String(err))
1535
1999
  return EXIT.failed
@@ -1544,31 +2008,27 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1544
2008
  deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
1545
2009
  deps.io.out('start a new Cursor session and try one prompt again.')
1546
2010
  }
1547
- deps.io.out('A companion-device answer is submitted as one follow-up user message.')
1548
- deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.')
2011
+ deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
2012
+ deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
2013
+ deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
1549
2014
  return EXIT.ok
1550
2015
  }
1551
2016
 
1552
- let document
2017
+ let merged: ReturnType<typeof mergeHooks>
1553
2018
  try {
1554
- document = loadSettings(file)
1555
- } catch (err) {
1556
- deps.io.err(String(err))
1557
- return EXIT.failed
1558
- }
1559
- const merged = mergeHooks(
1560
- document,
1561
- buildHookConfig({
1562
- execPath,
1563
- scriptPath,
1564
- replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1565
- graceSeconds: config.ask_grace_seconds.value,
1566
- harness,
1567
- }),
1568
- scriptPath,
1569
- )
1570
- try {
1571
- applyPlan(file, merged.document)
2019
+ merged = withTargetFileLock(file, () => {
2020
+ const document = loadSettings(file)
2021
+ const result = mergeHooks(
2022
+ document,
2023
+ buildHookConfig({
2024
+ adapterPath,
2025
+ harness,
2026
+ }),
2027
+ scriptPath,
2028
+ )
2029
+ applyPlan(file, result.document)
2030
+ return result
2031
+ })
1572
2032
  } catch (err) {
1573
2033
  deps.io.err(String(err))
1574
2034
  return EXIT.failed
@@ -1581,18 +2041,22 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1581
2041
  deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
1582
2042
  }
1583
2043
  deps.io.out('')
1584
- deps.io.out(
1585
- config.require_idle.value
1586
- ? `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
1587
- `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
1588
- `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
1589
- `has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
1590
- `Run \`notifai config set require_idle false\` to be notified even while you are working.`
1591
- : `A question registered with \`notifai ask\` stays in the terminal for ` +
1592
- `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
1593
- `whether or not you are at this machine ` +
1594
- `(\`notifai config set require_idle false\` is already in effect).`,
1595
- )
2044
+ if (config.require_idle.value) {
2045
+ deps.io.out(
2046
+ `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
2047
+ `nothing is pushed. A question registered with \`notifai ask\` goes to your devices ` +
2048
+ (config.ask_grace_seconds.value === 0
2049
+ ? 'as soon as the machine meets that idle threshold. '
2050
+ : `once its ${config.ask_grace_seconds.value}s grace window, counted from registration, has elapsed and the machine meets that idle threshold. `) +
2051
+ `Run \`notifai config set require_idle false\` to be notified even while you are working.`,
2052
+ )
2053
+ } else {
2054
+ deps.io.out(
2055
+ config.ask_grace_seconds.value === 0
2056
+ ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
2057
+ : `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`,
2058
+ )
2059
+ }
1596
2060
  if (config.require_idle.value) {
1597
2061
  deps.io.out(
1598
2062
  'If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.',
@@ -1636,18 +2100,23 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1636
2100
  function installOpencodePlugin(
1637
2101
  deps: CommandDeps,
1638
2102
  file: string,
1639
- options: { execPath: string; scriptPath: string; timeoutSeconds: number },
2103
+ options: { adapterPath: string; timeoutSeconds: number },
1640
2104
  ): number {
1641
- if (existsSync(file)) {
1642
- const existing = readFileSync(file, 'utf8')
1643
- if (!isOurOpencodePlugin(existing)) {
1644
- deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`)
1645
- return EXIT.failed
1646
- }
1647
- }
1648
2105
  try {
1649
- mkdirSync(path.dirname(file), { recursive: true })
1650
- writeFileSync(file, opencodePluginSource(options), { mode: 0o644 })
2106
+ withTargetFileLock(file, () => {
2107
+ if (existsSync(file)) {
2108
+ assertOwnedRegularFile(file)
2109
+ const existing = readFileSync(file, 'utf8')
2110
+ if (!isOurOpencodePlugin(existing)) {
2111
+ throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
2112
+ }
2113
+ }
2114
+ atomicWriteFileSync(file, opencodePluginSource(options), {
2115
+ mode: 0o600,
2116
+ preserveMode: false,
2117
+ requireCurrentUserOwner: true,
2118
+ })
2119
+ })
1651
2120
  } catch (err) {
1652
2121
  deps.io.err(String(err))
1653
2122
  return EXIT.failed
@@ -1657,9 +2126,9 @@ function installOpencodePlugin(
1657
2126
  deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
1658
2127
  deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
1659
2128
  deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
1660
- deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt')
1661
- deps.io.out('collects a device answer and adds it to the agent context. Use `notifai')
1662
- deps.io.out('send --reply` only when the answer must return before another prompt.')
2129
+ deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
2130
+ deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
2131
+ deps.io.out('the answer must return to the agent without another human prompt.')
1663
2132
  deps.io.out('')
1664
2133
  deps.io.out('Restart OpenCode: it loads plugins once at start.')
1665
2134
  return EXIT.ok
@@ -1670,35 +2139,46 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1670
2139
  if (!harness) return EXIT.usage
1671
2140
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
1672
2141
  const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
1673
- if (!existsSync(file)) {
1674
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
1675
- return EXIT.ok
1676
- }
1677
2142
  if (harness === 'opencode') {
1678
- // We own the whole file, but only if we wrote it.
1679
- if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1680
- deps.io.out(`Left ${file} alone: Notifai did not write it.`)
1681
- return EXIT.ok
1682
- }
1683
- rmSync(file, { force: true })
1684
- deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
1685
- return EXIT.ok
1686
- }
1687
- if (harness === 'cursor') {
1688
- let document
1689
2143
  try {
1690
- document = loadCursorSettings(file)
2144
+ return withTargetFileLock(file, () => {
2145
+ if (!existsSync(file)) {
2146
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
2147
+ return EXIT.ok
2148
+ }
2149
+ assertOwnedRegularFile(file)
2150
+ // We own the whole file, but only if we wrote it.
2151
+ if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
2152
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`)
2153
+ return EXIT.ok
2154
+ }
2155
+ rmSync(file, { force: true })
2156
+ deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
2157
+ return EXIT.ok
2158
+ })
1691
2159
  } catch (err) {
1692
2160
  deps.io.err(String(err))
1693
2161
  return EXIT.failed
1694
2162
  }
1695
- const stripped = removeCursorHooks(document, scriptPath)
2163
+ }
2164
+ if (harness === 'cursor') {
2165
+ let stripped: ReturnType<typeof removeCursorHooks> | null
1696
2166
  try {
1697
- applyPlan(file, stripped.document)
2167
+ stripped = withTargetFileLock(file, () => {
2168
+ if (!existsSync(file)) return null
2169
+ const document = loadCursorSettings(file)
2170
+ const result = removeCursorHooks(document, scriptPath)
2171
+ applyPlan(file, result.document)
2172
+ return result
2173
+ })
1698
2174
  } catch (err) {
1699
2175
  deps.io.err(String(err))
1700
2176
  return EXIT.failed
1701
2177
  }
2178
+ if (stripped === null) {
2179
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
2180
+ return EXIT.ok
2181
+ }
1702
2182
  deps.io.out(
1703
2183
  stripped.replaced.length > 0
1704
2184
  ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
@@ -1706,19 +2186,22 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1706
2186
  )
1707
2187
  return EXIT.ok
1708
2188
  }
1709
- let document
2189
+ let stripped: ReturnType<typeof removeHooks> | null
1710
2190
  try {
1711
- document = loadSettings(file)
2191
+ stripped = withTargetFileLock(file, () => {
2192
+ if (!existsSync(file)) return null
2193
+ const document = loadSettings(file)
2194
+ const result = removeHooks(document, scriptPath)
2195
+ applyPlan(file, result.document)
2196
+ return result
2197
+ })
1712
2198
  } catch (err) {
1713
2199
  deps.io.err(String(err))
1714
2200
  return EXIT.failed
1715
2201
  }
1716
- const stripped = removeHooks(document, scriptPath)
1717
- try {
1718
- applyPlan(file, stripped.document)
1719
- } catch (err) {
1720
- deps.io.err(String(err))
1721
- return EXIT.failed
2202
+ if (stripped === null) {
2203
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
2204
+ return EXIT.ok
1722
2205
  }
1723
2206
  deps.io.out(
1724
2207
  stripped.replaced.length > 0
@@ -1728,6 +2211,17 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
1728
2211
  return EXIT.ok
1729
2212
  }
1730
2213
 
2214
+ function assertOwnedRegularFile(file: string): void {
2215
+ const stat = lstatSync(file)
2216
+ if (stat.isSymbolicLink() || !stat.isFile()) {
2217
+ throw new Error(`${file} is not a regular file; refusing to read or replace it.`)
2218
+ }
2219
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
2220
+ if (uid !== undefined && stat.uid !== uid) {
2221
+ throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
2222
+ }
2223
+ }
2224
+
1731
2225
  function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
1732
2226
  if (requested !== undefined) {
1733
2227
  if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
@@ -1745,14 +2239,14 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
1745
2239
  }
1746
2240
 
1747
2241
  // ---------------------------------------------------------------------------
1748
- // config show / set
2242
+ // config show / set / unset
1749
2243
  // ---------------------------------------------------------------------------
1750
2244
 
1751
2245
  export function configShowCommand(
1752
2246
  deps: CommandDeps,
1753
- flags: { json?: boolean; explain?: boolean },
2247
+ flags: { json?: boolean; explain?: boolean; plain?: boolean },
1754
2248
  ): number {
1755
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
2249
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1756
2250
  if (flags.json) {
1757
2251
  const output = Object.fromEntries(
1758
2252
  CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]),
@@ -1760,47 +2254,198 @@ export function configShowCommand(
1760
2254
  deps.io.out(JSON.stringify(output, null, 2))
1761
2255
  return EXIT.ok
1762
2256
  }
1763
- for (const key of CONFIG_KEYS) {
1764
- const entry = config[key]
1765
- const provenance = flags.explain ? ` [${entry.source}]` : ''
1766
- deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`)
2257
+ // Anything that is not a person at a terminal keeps the flat `key = value`
2258
+ // form it has always had. Scripts parse this, and a prettier layout for an
2259
+ // audience that cannot see it would only be a breaking change.
2260
+ if (deps.io.interactive !== true || flags.plain === true) {
2261
+ for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
2262
+ return EXIT.ok
2263
+ }
2264
+ for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
2265
+ deps.io.out(line)
1767
2266
  }
1768
2267
  return EXIT.ok
1769
2268
  }
1770
2269
 
2270
+ /**
2271
+ * One setting, explained in full.
2272
+ *
2273
+ * The gap this closes: `config show` prints a value and provenance but that is
2274
+ * not enough to explain the setting's consequences. Every key already had a
2275
+ * careful explanation — in a TypeScript comment, read by everyone except the
2276
+ * person who needed it.
2277
+ */
2278
+ export function configExplainCommand(
2279
+ deps: CommandDeps,
2280
+ key: string,
2281
+ flags: { json?: boolean } = {},
2282
+ ): number {
2283
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
2284
+ deps.io.err(`Unknown setting "${key}".`)
2285
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
2286
+ return EXIT.usage
2287
+ }
2288
+ const configKey = key as ConfigKey
2289
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2290
+ const info = configInfo(configKey)
2291
+ const entry = config[configKey]
2292
+
2293
+ if (flags.json) {
2294
+ deps.io.out(
2295
+ JSON.stringify(
2296
+ {
2297
+ key: configKey,
2298
+ label: info.label,
2299
+ group: info.group,
2300
+ kind: info.kind,
2301
+ summary: info.summary,
2302
+ detail: info.detail,
2303
+ accepts: acceptedValues(configKey),
2304
+ ...(info.choices !== undefined ? { choices: info.choices } : {}),
2305
+ value: entry.value,
2306
+ source: entry.source,
2307
+ },
2308
+ null,
2309
+ 2,
2310
+ ),
2311
+ )
2312
+ return EXIT.ok
2313
+ }
2314
+
2315
+ if (deps.io.interactive !== true) {
2316
+ deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
2317
+ deps.io.out(info.detail.replace(/\n\n/g, '\n'))
2318
+ deps.io.out(`accepts: ${acceptedValues(configKey)}`)
2319
+ return EXIT.ok
2320
+ }
2321
+ for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
2322
+ return EXIT.ok
2323
+ }
2324
+
2325
+ /**
2326
+ * Closest config key by edit distance, or null when nothing is close.
2327
+ *
2328
+ * The threshold matters more than the algorithm: suggesting a key that shares
2329
+ * three letters with the typo sends the reader to the wrong setting with
2330
+ * confidence, which is worse than listing all fourteen and letting them look.
2331
+ */
2332
+ function nearestKey(input: string): string | null {
2333
+ let best: string | null = null
2334
+ let bestDistance = Number.POSITIVE_INFINITY
2335
+ for (const candidate of CONFIG_KEYS) {
2336
+ const distance = editDistance(input.toLowerCase(), candidate)
2337
+ if (distance < bestDistance) {
2338
+ bestDistance = distance
2339
+ best = candidate
2340
+ }
2341
+ }
2342
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
2343
+ }
2344
+
2345
+ function editDistance(a: string, b: string): number {
2346
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
2347
+ for (let i = 1; i <= a.length; i += 1) {
2348
+ let diagonal = previous[0]!
2349
+ previous[0] = i
2350
+ for (let j = 1; j <= b.length; j += 1) {
2351
+ const above = previous[j]!
2352
+ previous[j] = Math.min(
2353
+ previous[j]! + 1,
2354
+ previous[j - 1]! + 1,
2355
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
2356
+ )
2357
+ diagonal = above
2358
+ }
2359
+ }
2360
+ return previous[b.length]!
2361
+ }
2362
+
1771
2363
  export async function configSetCommand(
1772
2364
  deps: CommandDeps,
1773
2365
  key: string,
1774
2366
  rawValue: string,
1775
2367
  flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
1776
2368
  ): Promise<number> {
1777
- if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
1778
- deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`)
1779
- return EXIT.usage
1780
- }
2369
+ const configKey = configMutationKey(deps, key)
2370
+ if (configKey === null) return EXIT.usage
2371
+ const info = configInfo(configKey)
1781
2372
  let value: unknown = rawValue
1782
- if (NUMERIC_CONFIG_KEYS.includes(key as ConfigKey)) {
2373
+ if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
1783
2374
  const numeric = Number(rawValue)
1784
2375
  if (!Number.isInteger(numeric)) {
1785
- deps.io.err(`"${rawValue}" is not an integer.`)
2376
+ deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
1786
2377
  return EXIT.usage
1787
2378
  }
1788
- const bounds = configBounds(key as ConfigKey)
2379
+ const bounds = configBounds(configKey)
1789
2380
  if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1790
2381
  deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
1791
2382
  return EXIT.usage
1792
2383
  }
1793
2384
  value = numeric
1794
2385
  }
1795
- if (BOOLEAN_CONFIG_KEYS.includes(key as ConfigKey)) {
2386
+ if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
1796
2387
  if (rawValue !== 'true' && rawValue !== 'false') {
1797
2388
  deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
1798
2389
  return EXIT.usage
1799
2390
  }
1800
2391
  value = rawValue === 'true'
1801
2392
  }
2393
+ // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
2394
+ // value the sender would later reject — a typo that only surfaces at the
2395
+ // moment a notification fails to carry the sound you asked for.
2396
+ if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
2397
+ deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
2398
+ return EXIT.usage
2399
+ }
1802
2400
  if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
1803
2401
 
2402
+ const target = await configMutationTarget(deps, flags)
2403
+ if (target === null) return EXIT.usage
2404
+ if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
2405
+ deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`)
2406
+ deps.io.err(
2407
+ `Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`,
2408
+ )
2409
+ return EXIT.usage
2410
+ }
2411
+
2412
+ if (!flags.yes) {
2413
+ const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`)
2414
+ if (!confirmed) {
2415
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
2416
+ return EXIT.usage
2417
+ }
2418
+ }
2419
+
2420
+ const existing = existsSync(target.path)
2421
+ ? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
2422
+ : {}
2423
+ existing[key] = value
2424
+ mkdirSync(path.dirname(target.path), { recursive: true })
2425
+ writeFileSync(target.path, `${stringifyToml(existing)}\n`)
2426
+ deps.io.out(`Wrote ${key} to ${target.path}`)
2427
+ return EXIT.ok
2428
+ }
2429
+
2430
+ function configMutationKey(deps: CommandDeps, key: string): ConfigKey | null {
2431
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
2432
+ deps.io.err(`Unknown setting "${key}".`)
2433
+ const near = nearestKey(key)
2434
+ if (near !== null) deps.io.err(`Did you mean "${near}"?`)
2435
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
2436
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
2437
+ return null
2438
+ }
2439
+ return key as ConfigKey
2440
+ }
2441
+
2442
+ type ConfigMutationFlags = { project?: boolean; local?: boolean; session?: string; yes?: boolean }
2443
+ type ConfigMutationLayer = 'global' | 'project' | 'local' | 'session'
2444
+
2445
+ async function configMutationTarget(
2446
+ deps: CommandDeps,
2447
+ flags: ConfigMutationFlags,
2448
+ ): Promise<{ path: string; layer: ConfigMutationLayer } | null> {
1804
2449
  let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
1805
2450
  if (
1806
2451
  flags.session === undefined &&
@@ -1817,7 +2462,7 @@ export async function configSetCommand(
1817
2462
  ])
1818
2463
  if (selected === null) {
1819
2464
  deps.io.err('No configuration layer selected.')
1820
- return EXIT.usage
2465
+ return null
1821
2466
  }
1822
2467
  layer = selected
1823
2468
  }
@@ -1829,22 +2474,273 @@ export async function configSetCommand(
1829
2474
  : layer === 'project'
1830
2475
  ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
1831
2476
  : globalConfigPath(deps.env)
2477
+ return {
2478
+ path: targetPath,
2479
+ layer: flags.session ? 'session' : (layer as Exclude<ConfigMutationLayer, 'session'>),
2480
+ }
2481
+ }
1832
2482
 
2483
+ export async function configUnsetCommand(
2484
+ deps: CommandDeps,
2485
+ key: string,
2486
+ flags: ConfigMutationFlags,
2487
+ ): Promise<number> {
2488
+ const configKey = configMutationKey(deps, key)
2489
+ if (configKey === null) return EXIT.usage
2490
+ const target = await configMutationTarget(deps, flags)
2491
+ if (target === null) return EXIT.usage
2492
+ const existing = existsSync(target.path)
2493
+ ? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
2494
+ : {}
2495
+ if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
2496
+ deps.io.out(`${configKey} is not set in ${target.path}`)
2497
+ return EXIT.ok
2498
+ }
1833
2499
  if (!flags.yes) {
1834
- const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${targetPath}?`)
2500
+ const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`)
1835
2501
  if (!confirmed) {
1836
2502
  deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
1837
2503
  return EXIT.usage
1838
2504
  }
1839
2505
  }
1840
2506
 
1841
- const existing = existsSync(targetPath)
1842
- ? (parseToml(readFileSync(targetPath, 'utf8')) as Record<string, unknown>)
1843
- : {}
1844
- existing[key] = value
1845
- mkdirSync(path.dirname(targetPath), { recursive: true })
1846
- writeFileSync(targetPath, `${stringifyToml(existing)}\n`)
1847
- deps.io.out(`Wrote ${key} to ${targetPath}`)
2507
+ delete existing[configKey]
2508
+ if (Object.keys(existing).length === 0) {
2509
+ rmSync(target.path, { force: true })
2510
+ } else {
2511
+ writeFileSync(target.path, `${stringifyToml(existing)}\n`)
2512
+ }
2513
+ deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`)
2514
+ return EXIT.ok
2515
+ }
2516
+
2517
+ // ---------------------------------------------------------------------------
2518
+ // logs
2519
+ // ---------------------------------------------------------------------------
2520
+
2521
+ export interface LogsFlags {
2522
+ json?: boolean
2523
+ limit?: number
2524
+ all?: boolean
2525
+ since?: string
2526
+ level?: string
2527
+ event?: string[]
2528
+ run?: string
2529
+ request?: string
2530
+ session?: string
2531
+ project?: string
2532
+ allProjects?: boolean
2533
+ grep?: string
2534
+ path?: boolean
2535
+ clear?: boolean
2536
+ }
2537
+
2538
+ const LOG_RECORD_OPTIONS: readonly (readonly [keyof LogsFlags, string])[] = [
2539
+ ['limit', '--limit'],
2540
+ ['all', '--all'],
2541
+ ['since', '--since'],
2542
+ ['level', '--level'],
2543
+ ['event', '--event'],
2544
+ ['run', '--run'],
2545
+ ['request', '--request'],
2546
+ ['session', '--session'],
2547
+ ['project', '--project'],
2548
+ ['allProjects', '--all-projects'],
2549
+ ['grep', '--grep'],
2550
+ ]
2551
+
2552
+ /**
2553
+ * Deliberately small. The reader is usually a model with a finite context, and
2554
+ * a log command whose default answer is ten thousand lines gets used once.
2555
+ * Everything beyond this is reachable by asking for it.
2556
+ */
2557
+ const DEFAULT_LOG_LIMIT = 30
2558
+ /** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
2559
+ const MAX_LOG_LIMIT = 2_000
2560
+
2561
+ /** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
2562
+ export function parseSince(raw: string, now: number): number | null {
2563
+ const relative = /^(\d+)([smhd])$/.exec(raw.trim())
2564
+ if (relative !== null) {
2565
+ const amount = Number(relative[1])
2566
+ const unit = relative[2] as 's' | 'm' | 'h' | 'd'
2567
+ const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit]
2568
+ return now - amount * seconds * 1000
2569
+ }
2570
+ const absolute = Date.parse(raw)
2571
+ return Number.isNaN(absolute) ? null : absolute
2572
+ }
2573
+
2574
+ /**
2575
+ * What this machine recorded about itself.
2576
+ *
2577
+ * The retrieval half of the local log, and the half that decides whether the
2578
+ * log is worth having. Three things make it usable by an agent rather than
2579
+ * merely available to one: the default is bounded and scoped to this project,
2580
+ * the filters match the questions actually asked ("that run", "that
2581
+ * notification", "just the failures"), and `--json` gives the machine the
2582
+ * records untouched on stdout while every word of explanation goes to stderr.
2583
+ */
2584
+ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
2585
+ const selectedRetrievalOptions = LOG_RECORD_OPTIONS
2586
+ .filter(([key]) => {
2587
+ const value = flags[key]
2588
+ return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false
2589
+ })
2590
+ .map(([, name]) => name)
2591
+
2592
+ if (flags.path === true && flags.clear === true) {
2593
+ deps.io.err('Pass --path or --clear, not both.')
2594
+ return EXIT.usage
2595
+ }
2596
+ if (flags.path === true && selectedRetrievalOptions.length > 0) {
2597
+ deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
2598
+ return EXIT.usage
2599
+ }
2600
+ if (flags.clear === true && selectedRetrievalOptions.length > 0) {
2601
+ deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
2602
+ return EXIT.usage
2603
+ }
2604
+ if (flags.project !== undefined && flags.allProjects === true) {
2605
+ deps.io.err('Pass --project or --all-projects, not both.')
2606
+ return EXIT.usage
2607
+ }
2608
+ if (flags.limit !== undefined && flags.all === true) {
2609
+ deps.io.err('Pass --limit or --all, not both.')
2610
+ return EXIT.usage
2611
+ }
2612
+ if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
2613
+ deps.io.err('--limit must be a positive integer.')
2614
+ return EXIT.usage
2615
+ }
2616
+ if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
2617
+ deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`)
2618
+ return EXIT.usage
2619
+ }
2620
+
2621
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2622
+ const now = (deps.now ?? Date.now)()
2623
+
2624
+ if (flags.path === true) {
2625
+ const usage = logsDiskUsage(deps.env)
2626
+ const archives = archiveLogPaths(deps.env)
2627
+ if (flags.json === true) {
2628
+ deps.io.out(
2629
+ JSON.stringify(
2630
+ {
2631
+ active: activeLogPath(deps.env),
2632
+ archives,
2633
+ files: usage.files,
2634
+ bytes: usage.bytes,
2635
+ level: config.log_level.value,
2636
+ max_bytes: config.log_max_bytes.value,
2637
+ max_files: config.log_max_files.value,
2638
+ local_only: true,
2639
+ },
2640
+ null,
2641
+ 2,
2642
+ ),
2643
+ )
2644
+ return EXIT.ok
2645
+ }
2646
+ deps.io.out(activeLogPath(deps.env))
2647
+ for (const archive of archives) deps.io.out(archive)
2648
+ deps.io.err(
2649
+ `${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`,
2650
+ )
2651
+ return EXIT.ok
2652
+ }
2653
+
2654
+ if (flags.clear === true) {
2655
+ let removed = 0
2656
+ for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
2657
+ try {
2658
+ unlinkSync(file)
2659
+ removed += 1
2660
+ } catch {
2661
+ // Absent, or not ours to remove; either way there is nothing to report.
2662
+ }
2663
+ }
2664
+ deps.io.out(
2665
+ flags.json === true
2666
+ ? JSON.stringify({ cleared_files: removed })
2667
+ : `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`,
2668
+ )
2669
+ return EXIT.ok
2670
+ }
2671
+
2672
+ const query: LogQuery = {
2673
+ limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
2674
+ }
2675
+
2676
+ if (flags.since !== undefined) {
2677
+ const since = parseSince(flags.since, now)
2678
+ if (since === null) {
2679
+ deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`)
2680
+ return EXIT.usage
2681
+ }
2682
+ query.since = since
2683
+ }
2684
+ if (flags.level !== undefined) {
2685
+ if (!(LOG_LEVELS as readonly string[]).includes(flags.level)) {
2686
+ deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`)
2687
+ return EXIT.usage
2688
+ }
2689
+ query.level = flags.level as LogLevel
2690
+ }
2691
+ if (flags.event !== undefined && flags.event.length > 0) {
2692
+ const unknown = flags.event.filter((name) => !(LOG_EVENTS as readonly string[]).includes(name))
2693
+ if (unknown.length > 0) {
2694
+ deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`)
2695
+ return EXIT.usage
2696
+ }
2697
+ query.event = flags.event
2698
+ }
2699
+ if (flags.run !== undefined) query.run = flags.run
2700
+ if (flags.session !== undefined) query.session = flags.session
2701
+ if (flags.request !== undefined) query.request = flags.request
2702
+ if (flags.grep !== undefined) query.contains = flags.grep
2703
+
2704
+ // Scoped to this project by default. A machine commonly runs several agents
2705
+ // in several worktrees at once, and the answer to "what just happened" is
2706
+ // useless if three other projects are interleaved through it.
2707
+ const scope =
2708
+ flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined))
2709
+ if (scope !== undefined) query.project = scope
2710
+
2711
+ const result = readLogRecords(deps.env, query)
2712
+
2713
+ if (flags.json === true) {
2714
+ for (const record of result.records) deps.io.out(JSON.stringify(record))
2715
+ } else {
2716
+ for (const record of result.records) deps.io.out(renderRecord(record))
2717
+ }
2718
+
2719
+ if (result.records.length === 0) {
2720
+ if (config.log_level.value === 'off') {
2721
+ deps.io.err('Nothing is being recorded: log_level is off.')
2722
+ deps.io.err('Turn it on with `notifai config set log_level info --yes`.')
2723
+ } else if (logsDiskUsage(deps.env).files === 0) {
2724
+ deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.')
2725
+ } else {
2726
+ deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.')
2727
+ }
2728
+ return EXIT.ok
2729
+ }
2730
+
2731
+ // Everything explanatory goes to stderr so `--json` leaves stdout as clean
2732
+ // JSONL for whatever is parsing it.
2733
+ const notes: string[] = []
2734
+ if (scope !== undefined) notes.push(`project ${scope} (--all-projects for every project)`)
2735
+ if (result.more) {
2736
+ notes.push(
2737
+ query.limit! < MAX_LOG_LIMIT
2738
+ ? `more history behind this (-n ${Math.min(query.limit! * 4, MAX_LOG_LIMIT)} or --all)`
2739
+ : `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`,
2740
+ )
2741
+ }
2742
+ if (config.log_level.value !== 'debug') notes.push('`log_level = debug` records request detail')
2743
+ if (notes.length > 0) deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`)
1848
2744
  return EXIT.ok
1849
2745
  }
1850
2746
 
@@ -1857,7 +2753,7 @@ export async function configSetCommand(
1857
2753
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1858
2754
  * Keep this immutable and public because the command is printed to users.
1859
2755
  */
1860
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1'
2756
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0'
1861
2757
 
1862
2758
  function skillSourceParts(): { source: string; ref: string } | null {
1863
2759
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -2175,7 +3071,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
2175
3071
  const remedy = state.remedy
2176
3072
  if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
2177
3073
 
2178
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3074
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2179
3075
  const authed = authedClient(deps, config)
2180
3076
  if (!authed) {
2181
3077
  deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
@@ -2326,7 +3222,7 @@ async function submitSetupProof(
2326
3222
  * the push, not that a companion process received it.
2327
3223
  */
2328
3224
  async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
2329
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3225
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2330
3226
  const authed = authedClient(deps, config)
2331
3227
  if (!authed) return 'failed'
2332
3228
 
@@ -2694,7 +3590,7 @@ export async function assessReadiness(
2694
3590
  deps: CommandDeps,
2695
3591
  options: { skillScope?: SkillScope } = {},
2696
3592
  ): Promise<Readiness> {
2697
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3593
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2698
3594
  const states: ReadinessState[] = []
2699
3595
  let accountClient: ApiClient | null = null
2700
3596
  let accountDevices: RoutableDevice[] | null = null
@@ -3012,7 +3908,13 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
3012
3908
  // costs nothing.
3013
3909
  if (deps.io.interactive === true && deps.io.check) {
3014
3910
  await deps.io.intro?.('Notifai doctor')
3015
- for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
3911
+ for (const s of readiness.states) {
3912
+ // Four states, not two. Rendering `optional-gap` and `unknown` as
3913
+ // successes made a green report that had never checked half of what it
3914
+ // listed, and put a tick beside things the reader had deliberately
3915
+ // declined.
3916
+ await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
3917
+ }
3016
3918
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
3017
3919
  } else {
3018
3920
  for (const s of readiness.states) {
@@ -3031,6 +3933,20 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
3031
3933
  return ok ? EXIT.ok : EXIT.failed
3032
3934
  }
3033
3935
 
3936
+ /** Readiness status as a report tone. */
3937
+ function doctorTone(status: StateStatus): Tone {
3938
+ switch (status) {
3939
+ case 'ready':
3940
+ return 'ok'
3941
+ case 'gap':
3942
+ return 'bad'
3943
+ case 'optional-gap':
3944
+ return 'warn'
3945
+ case 'unknown':
3946
+ return 'pending'
3947
+ }
3948
+ }
3949
+
3034
3950
  /** One line telling the reader what to actually do about a state. */
3035
3951
  function remedyLine(state: ReadinessState): string {
3036
3952
  const remedy = state.remedy
@@ -3059,23 +3975,21 @@ function remedyLine(state: ReadinessState): string {
3059
3975
  *
3060
3976
  * Not everything failed is in the way. A pointer that has never been
3061
3977
  * published is the normal condition of an install thirty seconds old — the
3062
- * next prompt fixes it and no command can — and OpenCode's inability to
3063
- * resume an idle turn is a property of that harness rather than a fault in
3064
- * this setup. Treating those as blocking would mean `init` could never
3065
- * finish for an OpenCode user, or could only finish after a session had
3066
- * already run — so they report as things worth knowing rather than things to
3978
+ * next prompt fixes it and no command can. Treating that as blocking would
3979
+ * mean `init` could only finish after a session had already run — so it
3980
+ * reports as something worth knowing rather than something to
3067
3981
  * fix, and `init` walks on to the states it can actually close, delivery
3068
3982
  * proof included. Which failures are informational, and what the true remedy
3069
3983
  * is, is each check's own call (`HookCheck`).
3070
3984
  */
3071
3985
  function hookStates(deps: CommandDeps): ReadinessState[] {
3072
- const installations = findInstallations(deps.cwd, deps.env)
3986
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
3073
3987
  const active = activeHarnessSession(deps.env)
3074
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3988
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
3075
3989
  const settings: ReadinessState = {
3076
3990
  id: 'question-routing-settings',
3077
3991
  title: 'Question routing settings',
3078
- status: 'ready',
3992
+ status: config.ask_notifications.value ? 'ready' : 'gap',
3079
3993
  detail: [
3080
3994
  `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
3081
3995
  `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
@@ -3083,6 +3997,15 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3083
3997
  `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
3084
3998
  `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
3085
3999
  ].join(', '),
4000
+ ...(config.ask_notifications.value
4001
+ ? {}
4002
+ : {
4003
+ remedy: {
4004
+ by: 'cli' as const,
4005
+ summary: 'enable asynchronous question routing',
4006
+ command: 'notifai config set ask_notifications true',
4007
+ },
4008
+ }),
3086
4009
  }
3087
4010
  if (installations.length === 0) {
3088
4011
  return [
@@ -3108,7 +4031,7 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3108
4031
  }
3109
4032
 
3110
4033
  /** Real but not in the way; see the note above. */
3111
- const informational = new Set(['hooks (opencode continuation)'])
4034
+ const informational = new Set<string>()
3112
4035
  return [
3113
4036
  ...hookChecks(deps).map((check) => ({
3114
4037
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
@@ -3153,7 +4076,7 @@ interface HookCheck {
3153
4076
 
3154
4077
  function hookChecks(deps: CommandDeps): HookCheck[] {
3155
4078
  const checks: HookCheck[] = []
3156
- const installations = findInstallations(deps.cwd, deps.env)
4079
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
3157
4080
 
3158
4081
  // Not having hooks is a setup someone chose, not a fault: `send` works
3159
4082
  // without them. A setup that cannot work is what deserves to go red.
@@ -3196,7 +4119,16 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
3196
4119
  }),
3197
4120
  })
3198
4121
  if (activeInstallations.length > 0) {
3199
- const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
4122
+ const pointer =
4123
+ active.sessionId === undefined
4124
+ ? null
4125
+ : readMatchingProjectSessionPointer(
4126
+ deps.cwd,
4127
+ deps.env,
4128
+ (deps.now ?? Date.now)(),
4129
+ active.sessionId,
4130
+ active.harness,
4131
+ )
3200
4132
  if (pointer === null) {
3201
4133
  // The normal condition of hooks installed moments ago: the pointer
3202
4134
  // appears when the harness next fires a hook, and no command can
@@ -3213,23 +4145,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
3213
4145
  },
3214
4146
  })
3215
4147
  } else {
3216
- const matches =
3217
- pointer.harness === active.harness &&
3218
- (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3219
4148
  checks.push({
3220
4149
  name: 'hooks (active session)',
3221
- ok: matches,
3222
- detail: matches
3223
- ? `the project pointer belongs to the active ${active.label} session`
3224
- : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3225
- ...(matches
3226
- ? {}
3227
- : {
3228
- remedy: {
3229
- summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
3230
- command: 'notifai doctor',
3231
- },
3232
- }),
4150
+ ok: true,
4151
+ detail: `the concurrent project index contains the active ${active.label} session`,
3233
4152
  })
3234
4153
  }
3235
4154
  }
@@ -3257,21 +4176,41 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
3257
4176
  const adapterProblems = installations.flatMap((installation) =>
3258
4177
  (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
3259
4178
  )
4179
+ const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
4180
+ adapterProblems.push(...sharedAdapter.problems)
3260
4181
  if (adapterProblems.length > 0) {
3261
4182
  checks.push({
3262
4183
  name: 'hooks (adapter)',
3263
4184
  ok: false,
4185
+ // A machine-global install for a harness that is not active in this
4186
+ // project is useful diagnosis, but it must not block unrelated init.
4187
+ informational: active === null && installations.every((installation) => installation.global),
3264
4188
  detail: adapterProblems.join('; '),
3265
4189
  })
3266
4190
  }
3267
4191
 
3268
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
3269
- const requiredTimeout = blockingHookTimeoutSeconds(
3270
- config.ask_grace_seconds.value,
3271
- config.hook_reply_timeout_seconds.value,
3272
- )
4192
+ const trustProblems = codexTrustProblems(installations, deps.env)
4193
+ checks.push({
4194
+ name: 'hooks (trust)',
4195
+ ok: trustProblems.length === 0,
4196
+ detail:
4197
+ trustProblems.length === 0
4198
+ ? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
4199
+ : `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
4200
+ ...(trustProblems.length === 0
4201
+ ? {}
4202
+ : {
4203
+ remedy: {
4204
+ summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
4205
+ command: '/hooks',
4206
+ },
4207
+ }),
4208
+ })
4209
+
4210
+ const requiredTimeout = blockingHookTimeoutSeconds()
3273
4211
  const shortTimeouts = installations.flatMap((installation) =>
3274
4212
  installation.handlers
4213
+ .filter(() => installation.harness !== 'codex')
3275
4214
  .filter((handler) => handlerEvent(handler.command) === 'stop')
3276
4215
  .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
3277
4216
  .map(
@@ -3284,26 +4223,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
3284
4223
  ok: shortTimeouts.length === 0,
3285
4224
  detail:
3286
4225
  shortTimeouts.length === 0
3287
- ? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
4226
+ ? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
3288
4227
  : shortTimeouts.join('; '),
3289
4228
  })
3290
4229
 
3291
- // Two checkouts each installing hooks means both fire for the same event, and
3292
- // the user gets every question twice.
3293
- //
3294
- // Compared *within* a harness, not across. Only one harness runs a given
3295
- // session, so having Claude Code and OpenCode both set up is the ordinary
3296
- // case and not a duplicate — comparing them turned a healthy machine red.
4230
+ // Project and global definitions for one harness both fire. Stable adapter
4231
+ // identity deliberately makes their command bytes equal, so comparing
4232
+ // command targets would now hide this duplicate rather than diagnose it.
4233
+ // Different harnesses remain independent: only the active one runs.
3297
4234
  const duplicated = [...new Set(installations.map((i) => i.harness))]
3298
4235
  .map((harness) => ({
3299
4236
  harness,
3300
- scripts: new Set(
3301
- installations
3302
- .filter((i) => i.harness === harness)
3303
- .flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? '')),
3304
- ),
4237
+ installations: installations.filter((i) => i.harness === harness),
3305
4238
  }))
3306
- .filter((entry) => entry.scripts.size > 1)
4239
+ .filter((entry) => entry.installations.length > 1)
3307
4240
  if (duplicated.length > 0) {
3308
4241
  checks.push({
3309
4242
  name: 'hooks (duplicates)',
@@ -3311,43 +4244,86 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
3311
4244
  detail: duplicated
3312
4245
  .map(
3313
4246
  (entry) =>
3314
- `${entry.harness}: ${entry.scripts.size} different Notifai builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`,
4247
+ `${entry.harness}: ${entry.installations.length} hook definitions are active, so each event will fire all of them. Keep either project or global routing and uninstall the other: ${entry.installations.map((installation) => installation.file).join(', ')}`,
3315
4248
  )
3316
4249
  .join('; '),
3317
4250
  })
3318
4251
  }
3319
4252
 
3320
- const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3321
- const fired = firedPointer !== null
4253
+ if (active !== null && activeInstallations.length > 0) {
4254
+ const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
4255
+ checks.push({
4256
+ name: 'hooks (question admission)',
4257
+ ok: admissionProblems.length === 0,
4258
+ detail:
4259
+ admissionProblems.length === 0
4260
+ ? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
4261
+ : admissionProblems.join('; '),
4262
+ })
4263
+ }
4264
+
4265
+ const firedPointer =
4266
+ active?.sessionId === undefined
4267
+ ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
4268
+ : readMatchingProjectSessionPointer(
4269
+ deps.cwd,
4270
+ deps.env,
4271
+ (deps.now ?? Date.now)(),
4272
+ active.sessionId,
4273
+ active.harness,
4274
+ )
4275
+ const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
4276
+ const promptFired = firedState?.last_prompt_at !== undefined
4277
+ const stopFired = firedState?.last_stop_at !== undefined
4278
+ const fired = firedPointer !== null && promptFired && stopFired
3322
4279
  const activationInstallations =
3323
4280
  active !== null && activeInstallations.length > 0 ? activeInstallations : installations
3324
4281
  checks.push({
3325
4282
  name: 'hooks (fired)',
3326
4283
  ok: fired,
3327
- // Never-fired is evidence about the past, not a fault in the setup, and
3328
- // the cure is a prompt no command can send.
3329
- informational: true,
4284
+ // A wholly fresh install is informational. Once UserPromptSubmit has fired,
4285
+ // a missing Stop is a broken route, not missing historical evidence.
4286
+ informational: firedPointer === null,
3330
4287
  detail: fired
3331
- ? 'a session in this directory has run them'
3332
- : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
4288
+ ? 'a session in this directory has run UserPromptSubmit and Stop'
4289
+ : firedPointer === null
4290
+ ? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
4291
+ : `the routed session has fired ${promptFired ? 'UserPromptSubmit' : 'neither required event'}, but Stop has not been observed — end one harmless turn, send a new prompt, then check again`,
3333
4292
  ...(fired
3334
4293
  ? {}
3335
4294
  : {
3336
4295
  remedy: {
3337
- summary: 'send one prompt in a session here, then re-check',
4296
+ summary:
4297
+ firedPointer === null
4298
+ ? 'send one prompt in a session here, then re-check'
4299
+ : 'end one harmless turn, send a new prompt, then re-check',
3338
4300
  command: 'notifai doctor',
3339
4301
  },
3340
4302
  }),
3341
4303
  })
3342
4304
 
3343
- if (installations.some((installation) => installation.harness === 'opencode')) {
3344
- checks.push({
3345
- name: 'hooks (opencode continuation)',
3346
- ok: false,
3347
- detail:
3348
- 'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
3349
- })
3350
- }
4305
+ const continuationInstallations =
4306
+ active !== null ? activeInstallations : installations
4307
+ const continuationHarnesses = [
4308
+ ...new Set(continuationInstallations.map((installation) => installation.harness)),
4309
+ ]
4310
+ checks.push({
4311
+ name: 'hooks (answer continuation)',
4312
+ ok:
4313
+ continuationHarnesses.length > 0 &&
4314
+ continuationHarnesses.every(
4315
+ (harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
4316
+ ),
4317
+ informational: active === null,
4318
+ detail:
4319
+ continuationHarnesses.length === 0
4320
+ ? active === null
4321
+ ? 'no installed harness route to assess'
4322
+ : `the active ${active.label} session has no matching continuation adapter`
4323
+ : continuationHarnesses
4324
+ .map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
4325
+ .join('; '),
4326
+ })
3351
4327
 
3352
4328
  const stray = codexStrayWorktreeCheck(deps)
3353
4329
  if (stray !== null) checks.push(stray)
@@ -3463,11 +4439,21 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
3463
4439
  error: (next) => progress.error(next),
3464
4440
  }
3465
4441
  },
3466
- check: async (ok, message) => {
4442
+ check: async (ok, message, tone) => {
3467
4443
  if (!interactive()) return
3468
4444
  const { log } = await clack()
3469
- if (ok) log.success(message)
3470
- else log.error(message)
4445
+ switch (tone ?? (ok ? 'ok' : 'bad')) {
4446
+ case 'ok':
4447
+ return log.success(message)
4448
+ case 'warn':
4449
+ return log.warn(message)
4450
+ case 'pending':
4451
+ // Never checked. `log.info` reads as neutral, which is the honest
4452
+ // rendering for a state no evidence was gathered about.
4453
+ return log.info(message)
4454
+ case 'bad':
4455
+ return log.error(message)
4456
+ }
3471
4457
  },
3472
4458
  openUrl: (url) => {
3473
4459
  try {