@raidiant/notifai 5.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +2 -2
  3. package/dist/atomic-file.js +13 -0
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +8 -3
  6. package/dist/client.js +24 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands-auth.d.ts +14 -0
  9. package/dist/commands-auth.js +154 -0
  10. package/dist/commands-auth.js.map +1 -0
  11. package/dist/commands-config.d.ts +31 -0
  12. package/dist/commands-config.js +246 -0
  13. package/dist/commands-config.js.map +1 -0
  14. package/dist/commands-core.d.ts +102 -0
  15. package/dist/commands-core.js +135 -0
  16. package/dist/commands-core.js.map +1 -0
  17. package/dist/commands-devices.d.ts +31 -0
  18. package/dist/commands-devices.js +124 -0
  19. package/dist/commands-devices.js.map +1 -0
  20. package/dist/commands-doctor.d.ts +24 -0
  21. package/dist/commands-doctor.js +1011 -0
  22. package/dist/commands-doctor.js.map +1 -0
  23. package/dist/commands-harness-context.d.ts +65 -0
  24. package/dist/commands-harness-context.js +97 -0
  25. package/dist/commands-harness-context.js.map +1 -0
  26. package/dist/commands-hook-shape.d.ts +17 -0
  27. package/dist/commands-hook-shape.js +37 -0
  28. package/dist/commands-hook-shape.js.map +1 -0
  29. package/dist/commands-hooks.d.ts +115 -0
  30. package/dist/commands-hooks.js +1225 -0
  31. package/dist/commands-hooks.js.map +1 -0
  32. package/dist/commands-init.d.ts +35 -0
  33. package/dist/commands-init.js +580 -0
  34. package/dist/commands-init.js.map +1 -0
  35. package/dist/commands-io.d.ts +2 -0
  36. package/dist/commands-io.js +110 -0
  37. package/dist/commands-io.js.map +1 -0
  38. package/dist/commands-logs.d.ts +30 -0
  39. package/dist/commands-logs.js +201 -0
  40. package/dist/commands-logs.js.map +1 -0
  41. package/dist/commands-send-support.d.ts +44 -0
  42. package/dist/commands-send-support.js +183 -0
  43. package/dist/commands-send-support.js.map +1 -0
  44. package/dist/commands-send.d.ts +33 -0
  45. package/dist/commands-send.js +519 -0
  46. package/dist/commands-send.js.map +1 -0
  47. package/dist/commands-setup-proof.d.ts +15 -0
  48. package/dist/commands-setup-proof.js +53 -0
  49. package/dist/commands-setup-proof.js.map +1 -0
  50. package/dist/commands-skill.d.ts +10 -0
  51. package/dist/commands-skill.js +71 -0
  52. package/dist/commands-skill.js.map +1 -0
  53. package/dist/commands.d.ts +12 -387
  54. package/dist/commands.js +12 -4536
  55. package/dist/commands.js.map +1 -1
  56. package/dist/config-schema.js +1 -1
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +4 -4
  59. package/dist/config.js +6 -4
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.js +13 -3
  62. package/dist/credentials.js.map +1 -1
  63. package/dist/generated-session-label.d.ts +2 -0
  64. package/dist/generated-session-label.js +152 -0
  65. package/dist/generated-session-label.js.map +1 -0
  66. package/dist/hooks.d.ts +2 -2
  67. package/dist/hooks.js +127 -99
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/interactive.js +4 -4
  70. package/dist/interactive.js.map +1 -1
  71. package/dist/invocation-context.d.ts +3 -0
  72. package/dist/invocation-context.js +23 -6
  73. package/dist/invocation-context.js.map +1 -1
  74. package/dist/logging.js +1 -0
  75. package/dist/logging.js.map +1 -1
  76. package/dist/main.js +3 -3
  77. package/dist/main.js.map +1 -1
  78. package/dist/opencode-plugin.js +18 -3
  79. package/dist/opencode-plugin.js.map +1 -1
  80. package/dist/readiness.d.ts +3 -1
  81. package/dist/readiness.js.map +1 -1
  82. package/dist/session-labels.d.ts +30 -0
  83. package/dist/session-labels.js +278 -0
  84. package/dist/session-labels.js.map +1 -0
  85. package/package.json +2 -2
  86. package/src/atomic-file.ts +12 -0
  87. package/src/client.test.ts +90 -1
  88. package/src/client.ts +35 -2
  89. package/src/commands-auth.ts +174 -0
  90. package/src/commands-config.ts +307 -0
  91. package/src/commands-core.ts +240 -0
  92. package/src/commands-devices.ts +150 -0
  93. package/src/commands-doctor.ts +1209 -0
  94. package/src/commands-harness-context.ts +134 -0
  95. package/src/commands-hook-shape.ts +53 -0
  96. package/src/commands-hooks.ts +1586 -0
  97. package/src/commands-init.ts +721 -0
  98. package/src/commands-io.ts +108 -0
  99. package/src/commands-logs.ts +243 -0
  100. package/src/commands-send-support.ts +227 -0
  101. package/src/commands-send.ts +623 -0
  102. package/src/commands-setup-proof.ts +71 -0
  103. package/src/commands-skill.ts +84 -0
  104. package/src/commands.test.ts +567 -42
  105. package/src/commands.ts +58 -5614
  106. package/src/config-schema.ts +1 -1
  107. package/src/config.test.ts +9 -0
  108. package/src/config.ts +10 -5
  109. package/src/credentials.test.ts +25 -1
  110. package/src/credentials.ts +13 -3
  111. package/src/generated-session-label.ts +155 -0
  112. package/src/hooks.test.ts +355 -16
  113. package/src/hooks.ts +145 -97
  114. package/src/install-hooks.test.ts +84 -1
  115. package/src/interactive.test.ts +19 -1
  116. package/src/interactive.ts +4 -4
  117. package/src/invocation-context.test.ts +115 -16
  118. package/src/invocation-context.ts +32 -11
  119. package/src/logging.test.ts +15 -2
  120. package/src/logging.ts +1 -0
  121. package/src/main.ts +9 -3
  122. package/src/opencode-plugin.ts +18 -3
  123. package/src/readiness.ts +3 -1
  124. package/src/session-labels.test.ts +303 -0
  125. package/src/session-labels.ts +360 -0
  126. package/src/skill.test.ts +13 -0
@@ -0,0 +1,1209 @@
1
+ import { PLATFORMS, SHIPPED_CLI_CAPABILITIES, type RoutableDevice } from '@raidiant/notifai-protocol'
2
+ import { existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { inspectClaudeInbox, systemClaudeWakeAdapters } from './claude-wake.js'
5
+ import { ApiCallError, type ApiClient } from './client.js'
6
+ import { inspectCodexResume } from './codex-wake.js'
7
+ import { type CliConfig } from './config.js'
8
+ import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
9
+ import { inspectHookAdapter } from './hook-adapter.js'
10
+ import {
11
+ readLiveProjectSessionPointers,
12
+ readMatchingProjectSessionPointer,
13
+ readProjectSessionPointer,
14
+ readSessionState,
15
+ } from './hooks.js'
16
+ import {
17
+ BLOCKING_STOP_TIMEOUT_SECONDS,
18
+ CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
19
+ codexCoexistenceNotes,
20
+ codexHomeNote,
21
+ codexLayerDir,
22
+ codexLayerPaths,
23
+ codexProjectRoot,
24
+ codexRepresentationProblems,
25
+ codexTrustProblems,
26
+ detectedHarnesses,
27
+ findInstallations,
28
+ handlerEvent,
29
+ inspectCodexLayer,
30
+ type Installation,
31
+ } from './install-hooks.js'
32
+ import { inferInvocationContext } from './invocation-context.js'
33
+ import type { SkillScope } from './native-skills.js'
34
+ import {
35
+ firstBlocker,
36
+ type Readiness,
37
+ type ReadinessRefresh,
38
+ type ReadinessState,
39
+ type StateStatus,
40
+ } from './readiness.js'
41
+ import { packageVersion } from './release.js'
42
+ import type { Tone } from './ui/theme.js'
43
+ import {
44
+ EXIT,
45
+ UPDATE_CLI_COMMAND,
46
+ loadLoggedConfig,
47
+ makeClient,
48
+ resolvedBaseUrl,
49
+ type CommandDeps,
50
+ } from './commands-core.js'
51
+ import { deviceInstallRemedy, readyIosDevices, supportPageUrl } from './commands-devices.js'
52
+ import {
53
+ activeHarnessSession,
54
+ claudeSessionPid,
55
+ resolveActiveHarness,
56
+ type ActiveHarnessSession,
57
+ } from './commands-harness-context.js'
58
+ import { stopShapeProblems } from './commands-hook-shape.js'
59
+ import { HOOK_EVENTS, activeQuestionRouteProblems, hookActivationAdvice } from './commands-hooks.js'
60
+ import { observedCompanionReceipt, readSetupProof } from './commands-setup-proof.js'
61
+ import { skillReadiness } from './commands-skill.js'
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // doctor
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Server-owned support policy plus every shipped platform document. Artifact
69
+ * versions and document integers are structured inventory, never routing or a
70
+ * definition of "up to date".
71
+ */
72
+ async function compatibilityCheck(client: ApiClient): Promise<ReadinessState> {
73
+ try {
74
+ const [compatibility, documents] = await Promise.all([
75
+ client.compatibility(),
76
+ Promise.all(PLATFORMS.map((platform) => client.capabilities(platform))),
77
+ ])
78
+ const serverCapabilities = new Set(compatibility.server_capabilities)
79
+ const cliCapabilityIntersection = {
80
+ available: SHIPPED_CLI_CAPABILITIES.filter((capability) => serverCapabilities.has(capability)),
81
+ missing_on_server: SHIPPED_CLI_CAPABILITIES.filter(
82
+ (capability) => !serverCapabilities.has(capability),
83
+ ),
84
+ }
85
+ const technical = {
86
+ local: {
87
+ cli_version: packageVersion(),
88
+ capabilities: [...SHIPPED_CLI_CAPABILITIES],
89
+ },
90
+ server: compatibility,
91
+ capability_documents: documents.map((document) => ({
92
+ platform: document.platform,
93
+ schema_version: document.schema_version,
94
+ })),
95
+ cli_capability_intersection: cliCapabilityIntersection,
96
+ }
97
+ if (compatibility.cli.state === 'must_update') {
98
+ return {
99
+ id: 'contract',
100
+ title: 'Notifai update',
101
+ status: 'gap',
102
+ detail: "Notifai can't send notifications until you update.",
103
+ technical,
104
+ remedy: {
105
+ by: 'user-here',
106
+ summary: 'update Notifai',
107
+ command: UPDATE_CLI_COMMAND,
108
+ },
109
+ }
110
+ }
111
+ if (compatibility.cli.state === 'update_available') {
112
+ const scheduled = compatibility.cli.reason === 'sunset_scheduled'
113
+ return {
114
+ id: 'contract',
115
+ title: 'Notifai update',
116
+ status: 'optional-gap',
117
+ detail: scheduled
118
+ ? 'Update Notifai soon to keep sending notifications.'
119
+ : 'A newer Notifai is available.',
120
+ technical,
121
+ remedy: {
122
+ by: 'user-here',
123
+ summary: 'update Notifai',
124
+ command: UPDATE_CLI_COMMAND,
125
+ },
126
+ }
127
+ }
128
+ if (cliCapabilityIntersection.missing_on_server.length > 0) {
129
+ // Named capabilities are feature-scoped. A service that has not learned
130
+ // this CLI's optional feature yet does not make baseline Notification
131
+ // Requests unavailable, even when every document carries the same schema
132
+ // integer. Structured output names the gap; the human path stays on the
133
+ // local service-update sentence and never invents a version diagnosis.
134
+ return {
135
+ id: 'contract',
136
+ title: 'Notifai update',
137
+ status: 'optional-gap',
138
+ detail: 'The service is being updated; try again later.',
139
+ technical,
140
+ remedy: {
141
+ by: 'user-here',
142
+ summary: 'try again after the service update',
143
+ command: 'notifai doctor',
144
+ },
145
+ }
146
+ }
147
+ return {
148
+ id: 'contract',
149
+ title: 'Notifai update',
150
+ status: 'ready',
151
+ detail: 'Notifai can send notifications.',
152
+ technical,
153
+ }
154
+ } catch (err) {
155
+ return {
156
+ id: 'contract',
157
+ title: 'Notifai update',
158
+ status: 'optional-gap',
159
+ detail: 'The service is being updated; try again later.',
160
+ technical: {
161
+ error: err instanceof ApiCallError
162
+ ? { code: err.code, status: err.status }
163
+ : String(err),
164
+ },
165
+ remedy: {
166
+ by: 'user-here',
167
+ summary: 'try again after the service update',
168
+ command: 'notifai doctor',
169
+ },
170
+ }
171
+ }
172
+ }
173
+
174
+ function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
175
+ const configured = config.project.value
176
+ if (configured !== null) {
177
+ return {
178
+ id: 'project',
179
+ title: 'Project identity',
180
+ status: 'ready',
181
+ detail: `"${configured}" (${config.project.source})`,
182
+ }
183
+ }
184
+ const inferred = inferInvocationContext(deps.cwd).project
185
+ if (inferred !== null) {
186
+ return {
187
+ id: 'project',
188
+ title: 'Project identity',
189
+ status: 'optional-gap',
190
+ detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
191
+ remedy: {
192
+ by: 'cli',
193
+ summary: 'make the inferred Project identity explicit for every checkout',
194
+ command: 'notifai init',
195
+ },
196
+ }
197
+ }
198
+ return {
199
+ id: 'project',
200
+ title: 'Project identity',
201
+ status: 'optional-gap',
202
+ detail: 'the directory name has no characters a Project identifier can use',
203
+ remedy: {
204
+ by: 'cli',
205
+ summary: 'choose an explicit Project identifier',
206
+ command: 'notifai init --project-id my-project',
207
+ },
208
+ }
209
+ }
210
+
211
+ function remoteStatesFrom(previous: Readiness): {
212
+ credential: ReadinessState
213
+ server: ReadinessState
214
+ contract: ReadinessState
215
+ auth: ReadinessState
216
+ devices: ReadinessState
217
+ proof: ReadinessState
218
+ } | null {
219
+ const pick = (id: string): ReadinessState | undefined => previous.states.find((state) => state.id === id)
220
+ const credential = pick('credential')
221
+ const server = pick('server')
222
+ const contract = pick('contract')
223
+ const auth = pick('auth')
224
+ const devices = pick('devices')
225
+ const proof = pick('proof')
226
+ if (
227
+ credential === undefined ||
228
+ server === undefined ||
229
+ contract === undefined ||
230
+ auth === undefined ||
231
+ devices === undefined ||
232
+ proof === undefined
233
+ ) {
234
+ return null
235
+ }
236
+ return { credential, server, contract, auth, devices, proof }
237
+ }
238
+
239
+ function remoteInvalidatedByConfig(previous: Readiness, config: CliConfig): boolean {
240
+ if (config.base_url.source === 'default') return false
241
+ const server = previous.states.find((state) => state.id === 'server')
242
+ return server === undefined || !server.detail.includes(config.base_url.value)
243
+ }
244
+
245
+ /**
246
+ * Read the whole setup once, in dependency order.
247
+ *
248
+ * Descent stops where a prerequisite is missing: without a credential there is
249
+ * nothing to ask the server with, and without a reachable server a contract
250
+ * mismatch is unknowable rather than absent. Those downstream states report
251
+ * `unknown`, which is the honest answer and keeps a network outage from
252
+ * looking like a broken install.
253
+ */
254
+ export async function assessReadiness(
255
+ deps: CommandDeps,
256
+ options: {
257
+ skillScope?: SkillScope
258
+ previous?: Readiness
259
+ refresh?: readonly ReadinessRefresh[]
260
+ } = {},
261
+ ): Promise<Readiness> {
262
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
263
+ const previous = options.previous
264
+ const refresh = options.refresh
265
+ if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
266
+
267
+ const reuseRemote =
268
+ previous !== undefined &&
269
+ refresh !== undefined &&
270
+ !refresh.includes('remote') &&
271
+ !remoteInvalidatedByConfig(previous, config)
272
+ if (reuseRemote) {
273
+ const reused = remoteStatesFrom(previous)
274
+ if (reused !== null) {
275
+ return {
276
+ states: [
277
+ projectReadiness(deps, config),
278
+ reused.credential,
279
+ reused.server,
280
+ reused.contract,
281
+ reused.auth,
282
+ ...hookStates(deps),
283
+ await skillReadiness(deps, options.skillScope),
284
+ reused.devices,
285
+ reused.proof,
286
+ ],
287
+ }
288
+ }
289
+ }
290
+
291
+ const states: ReadinessState[] = []
292
+ let accountClient: ApiClient | null = null
293
+ let accountDevices: RoutableDevice[] | null = null
294
+
295
+ states.push(projectReadiness(deps, config))
296
+
297
+ const credential = deps.store.load()
298
+ states.push(
299
+ credential
300
+ ? {
301
+ id: 'credential',
302
+ title: 'This machine',
303
+ status: 'ready',
304
+ detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
305
+ }
306
+ : {
307
+ id: 'credential',
308
+ title: 'This machine',
309
+ status: 'gap',
310
+ detail: 'not paired with your account',
311
+ remedy: {
312
+ by: 'user-here',
313
+ summary: 'sign in — this opens your browser to approve the machine',
314
+ command: 'notifai login',
315
+ interactive: true,
316
+ },
317
+ },
318
+ )
319
+
320
+ const baseUrl = resolvedBaseUrl(config, credential)
321
+ const anon = makeClient(deps, baseUrl, null)
322
+ // A probe that throws is unreachable, not a crash: this runs against a
323
+ // half-configured machine by definition, which is where a client that
324
+ // cannot even be constructed properly shows up.
325
+ let reachable = false
326
+ try {
327
+ reachable = await anon.health()
328
+ } catch {
329
+ reachable = false
330
+ }
331
+ states.push(
332
+ reachable
333
+ ? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
334
+ : {
335
+ id: 'server',
336
+ title: 'Service',
337
+ status: 'gap',
338
+ detail: `cannot reach ${baseUrl}`,
339
+ remedy: {
340
+ by: 'user-here',
341
+ summary: 'check your network',
342
+ command: 'notifai doctor',
343
+ },
344
+ },
345
+ )
346
+
347
+ if (!reachable || !credential) {
348
+ states.push({
349
+ id: 'contract',
350
+ title: 'Notifai update',
351
+ status: 'unknown',
352
+ detail: !reachable
353
+ ? 'Not checked because the service is unreachable.'
354
+ : 'Not checked because this machine is not paired.',
355
+ })
356
+ } else {
357
+ const compatibilityClient = makeClient(
358
+ deps,
359
+ baseUrl,
360
+ `Bearer nfm_${credential.machineId}.${credential.secret}`,
361
+ )
362
+ states.push(await compatibilityCheck(compatibilityClient))
363
+ }
364
+
365
+ let accountEmail: string | null = null
366
+ let accountLookupFailed = false
367
+ if (!credential || !reachable) {
368
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
369
+ states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` })
370
+ } else {
371
+ const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
372
+ accountClient = client
373
+ try {
374
+ const [{ devices }, email] = await Promise.all([
375
+ client.listDevices(),
376
+ Promise.resolve()
377
+ .then(async () => (await client.accessStatus()).email)
378
+ .catch(() => null as string | null),
379
+ ])
380
+ accountDevices = devices
381
+ accountEmail = email
382
+ states.push({
383
+ id: 'auth',
384
+ title: 'Account',
385
+ status: 'ready',
386
+ detail: accountEmail
387
+ ? `machine ${credential.machineId} accepted (${accountEmail})`
388
+ : `machine ${credential.machineId} accepted`,
389
+ })
390
+ } catch (err) {
391
+ // A credential the server rejects is revocation, not absence, and the
392
+ // remedy is the same sign-in either way.
393
+ accountLookupFailed = true
394
+ states.push({
395
+ id: 'auth',
396
+ title: 'Account',
397
+ status: 'gap',
398
+ detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
399
+ remedy: {
400
+ by: 'user-here',
401
+ summary: 'this machine is no longer recognised; pair it again',
402
+ command: 'notifai login',
403
+ },
404
+ })
405
+ }
406
+ }
407
+
408
+ // Optional setup that works without a companion device must appear before the
409
+ // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
410
+ // are reachable without a phone.
411
+ states.push(...hookStates(deps))
412
+ states.push(await skillReadiness(deps, options.skillScope))
413
+
414
+ if (!credential || !reachable) {
415
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
416
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
417
+ } else if (accountLookupFailed || accountDevices === null) {
418
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
419
+ } else {
420
+ const devices = accountDevices
421
+ const iphoneDevices = devices.filter((device) => device.platform === 'ios')
422
+ const ready = readyIosDevices(iphoneDevices)
423
+ states.push(
424
+ ready.length > 0
425
+ ? {
426
+ id: 'devices',
427
+ title: 'Your devices',
428
+ status: 'ready',
429
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
430
+ }
431
+ : {
432
+ id: 'devices',
433
+ title: 'Your devices',
434
+ status: 'gap',
435
+ // The one gap that cannot be closed from this terminal, and the
436
+ // likeliest place a first setup is abandoned. Naming which of
437
+ // the three sub-states it is matters: "install the app" is
438
+ // useless advice to someone who installed it and denied the
439
+ // permission prompt. The live bridge is /support on the
440
+ // dashboard origin — not a placeholder, and not typed by hand.
441
+ detail:
442
+ iphoneDevices.length === 0
443
+ ? `no iPhone registered yet; install Notifai on iPhone via ${supportPageUrl(baseUrl)}`
444
+ : `${iphoneDevices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
445
+ remedy: {
446
+ by: 'user-elsewhere',
447
+ summary: deviceInstallRemedy({
448
+ baseUrl,
449
+ email: accountEmail,
450
+ devices: iphoneDevices,
451
+ }),
452
+ },
453
+ },
454
+ )
455
+ }
456
+
457
+ states.push(await setupProofState(deps, config, accountClient, accountDevices))
458
+
459
+ return { states }
460
+ }
461
+
462
+ async function setupProofState(
463
+ deps: CommandDeps,
464
+ config: CliConfig,
465
+ client: ApiClient | null,
466
+ devices: RoutableDevice[] | null,
467
+ ): Promise<ReadinessState> {
468
+ if (client === null || devices === null) {
469
+ return {
470
+ id: 'proof',
471
+ title: 'Delivery proof',
472
+ status: 'unknown',
473
+ detail: 'not checked — account and device readiness must be established first',
474
+ }
475
+ }
476
+
477
+ const ios = readyIosDevices(devices)
478
+ if (ios.length === 0) {
479
+ return {
480
+ id: 'proof',
481
+ title: 'Delivery proof',
482
+ status: 'unknown',
483
+ detail: 'not checked — no iPhone is ready',
484
+ }
485
+ }
486
+
487
+ const proof = readSetupProof(deps)
488
+ const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id)
489
+ if (proof === null || proof.project !== config.project.value || target === undefined) {
490
+ return {
491
+ id: 'proof',
492
+ title: 'Delivery proof',
493
+ status: 'gap',
494
+ detail:
495
+ "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
496
+ remedy: {
497
+ by: 'cli',
498
+ summary:
499
+ "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
500
+ command: 'notifai init',
501
+ },
502
+ }
503
+ }
504
+
505
+ try {
506
+ const snapshot = await client.evidence(proof.request_id)
507
+ const observed = observedCompanionReceipt(snapshot, proof.device_id)
508
+ if (observed) {
509
+ return {
510
+ id: 'proof',
511
+ title: 'Delivery proof',
512
+ status: 'ready',
513
+ detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
514
+ }
515
+ }
516
+ return {
517
+ id: 'proof',
518
+ title: 'Delivery proof',
519
+ status: 'gap',
520
+ detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
521
+ remedy: {
522
+ by: 'cli',
523
+ summary: 'check the same verification notification again',
524
+ command: 'notifai init',
525
+ },
526
+ }
527
+ } catch (err) {
528
+ return {
529
+ id: 'proof',
530
+ title: 'Delivery proof',
531
+ status: 'gap',
532
+ detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
533
+ remedy: {
534
+ by: 'cli',
535
+ summary: 'retry the existing verification evidence check',
536
+ command: 'notifai init',
537
+ },
538
+ }
539
+ }
540
+ }
541
+
542
+ export async function doctorCommand(
543
+ deps: CommandDeps,
544
+ flags: { json?: boolean },
545
+ options: { readiness?: Readiness } = {},
546
+ ): Promise<number> {
547
+ const readiness = options.readiness ?? (await assessReadiness(deps))
548
+ const blocker = firstBlocker(readiness)
549
+ const ok = blocker === null
550
+
551
+ // A human fallback exists only at a real terminal. Pipes, harnesses and
552
+ // non-TTY callers get the structured agent variant even without --json.
553
+ if (flags.json || deps.io.interactive !== true) {
554
+ deps.io.out(
555
+ JSON.stringify(
556
+ { ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states },
557
+ null,
558
+ 2,
559
+ ),
560
+ )
561
+ return ok ? EXIT.ok : EXIT.failed
562
+ }
563
+
564
+ const line = (s: ReadinessState) => `${s.title}: ${s.detail}`
565
+ await deps.io.intro?.('Notifai doctor')
566
+ for (const s of readiness.states) {
567
+ // Working software says nothing about versions. Soft/hard update states use
568
+ // only the closed sentence and the exact command, never schema/capability
569
+ // vocabulary or a server-provided action.
570
+ if (s.id === 'contract') {
571
+ if (s.status === 'ready' || s.status === 'unknown') continue
572
+ deps.io.out(s.detail)
573
+ if (s.remedy?.by !== 'user-elsewhere' && s.remedy?.command === UPDATE_CLI_COMMAND) {
574
+ deps.io.out(UPDATE_CLI_COMMAND)
575
+ }
576
+ continue
577
+ }
578
+ if (deps.io.check) {
579
+ await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
580
+ } else {
581
+ const mark =
582
+ s.status === 'gap'
583
+ ? 'FAIL'
584
+ : s.status === 'unknown'
585
+ ? ' ? '
586
+ : s.status === 'optional-gap'
587
+ ? ' --'
588
+ : 'ok '
589
+ deps.io.out(`${mark} ${line(s)}`)
590
+ }
591
+ }
592
+ await deps.io.outro?.(
593
+ ok
594
+ ? 'Everything looks good'
595
+ : blocker?.id === 'contract'
596
+ ? 'Update Notifai, then run doctor again'
597
+ : `Start with: ${remedyLine(blocker)}`,
598
+ )
599
+ return ok ? EXIT.ok : EXIT.failed
600
+ }
601
+
602
+ /** Readiness status as a report tone. */
603
+ function doctorTone(status: StateStatus): Tone {
604
+ switch (status) {
605
+ case 'ready':
606
+ return 'ok'
607
+ case 'gap':
608
+ return 'bad'
609
+ case 'optional-gap':
610
+ return 'warn'
611
+ case 'unknown':
612
+ return 'pending'
613
+ }
614
+ }
615
+
616
+ /** One line telling the reader what to actually do about a state. */
617
+ export function remedyLine(state: ReadinessState): string {
618
+ const remedy = state.remedy
619
+ if (!remedy) return state.detail
620
+ if (remedy.by === 'user-elsewhere') return remedy.summary
621
+ return remedy.command === undefined
622
+ ? remedy.summary
623
+ : `${remedy.summary} — run \`${remedy.command}\``
624
+ }
625
+
626
+ /**
627
+ * Whether the hook installation is internally ready, plus evidence that a
628
+ * project session has fired it before. This cannot prove future execution or
629
+ * end-to-end notification delivery without a live harness and device test.
630
+ *
631
+ * Every failure mode here was found the expensive way, by spawning a session
632
+ * and watching nothing happen: hooks not installed, installed but never fired,
633
+ * or left behind by an older build that named events this one does not serve.
634
+ */
635
+ /**
636
+ * Hook diagnostics as readiness states.
637
+ *
638
+ * A thin adapter over `hookChecks`, whose every branch was found the expensive
639
+ * way and is not worth re-deriving. The judgment added here is which failures
640
+ * actually stand in the way.
641
+ *
642
+ * Not everything failed is in the way. A pointer that has never been
643
+ * published is the normal condition of an install thirty seconds old — the
644
+ * next prompt fixes it and no command can. Treating that as blocking would
645
+ * mean `init` could only finish after a session had already run — so it
646
+ * reports as something worth knowing rather than something to
647
+ * fix, and `init` walks on to the states it can actually close, delivery
648
+ * proof included. Which failures are informational, and what the true remedy
649
+ * is, is each check's own call (`HookCheck`).
650
+ */
651
+ function hookStates(deps: CommandDeps): ReadinessState[] {
652
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
653
+ const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
654
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
655
+ const settings: ReadinessState = {
656
+ id: 'question-routing-settings',
657
+ title: 'Question routing settings',
658
+ status: config.ask_notifications.value ? 'ready' : 'gap',
659
+ detail: [
660
+ `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
661
+ `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
662
+ ].join(', '),
663
+ ...(config.ask_notifications.value
664
+ ? {}
665
+ : {
666
+ remedy: {
667
+ by: 'cli' as const,
668
+ summary: 'enable asynchronous question routing',
669
+ command: 'notifai config set ask_notifications true',
670
+ },
671
+ }),
672
+ }
673
+ if (installations.length === 0) {
674
+ return [
675
+ {
676
+ id: 'hooks',
677
+ title: 'Question routing',
678
+ status: active === null ? 'optional-gap' : 'gap',
679
+ detail:
680
+ active === null
681
+ ? 'hooks not installed, so questions stay in the terminal'
682
+ : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
683
+ remedy: {
684
+ by: 'cli',
685
+ summary: 'install harness hooks so questions reach your devices when you are away',
686
+ command:
687
+ active === null
688
+ ? 'notifai hooks install'
689
+ : `notifai hooks install --harness ${active.harness}`,
690
+ },
691
+ },
692
+ settings,
693
+ ]
694
+ }
695
+
696
+ /**
697
+ * A human title per check.
698
+ *
699
+ * Three checks used to collapse onto "Question routing", so a reader could not
700
+ * tell which of them had failed, and the rest fell through to their internal
701
+ * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
702
+ * thing to branch on; this is only what a person reads.
703
+ */
704
+ const CHECK_TITLES: Readonly<Record<string, string>> = {
705
+ hooks: 'Question routing',
706
+ 'hooks (detected)': 'Harnesses detected',
707
+ 'hooks (active harness)': 'Routing for this harness',
708
+ 'hooks (active session)': 'Routing for this session',
709
+ 'hooks (stale)': 'Hook definitions current',
710
+ 'hooks (adapter)': 'Hook adapter',
711
+ 'hooks (trust)': 'Codex hook trust',
712
+ 'hooks (stop shape)': 'Turn-end hook shape',
713
+ 'hooks (duplicates)': 'Duplicate hook installs',
714
+ 'hooks (codex representation)': 'Codex hook representation',
715
+ 'hooks (question admission)': 'Question admission',
716
+ 'hooks (fired)': 'Hooks have run here',
717
+ 'hooks (answer continuation)': 'How an answer returns',
718
+ 'hooks (wake route)': 'Direct wake route',
719
+ }
720
+
721
+ function checkTitle(name: string): string {
722
+ return CHECK_TITLES[name] ?? name
723
+ }
724
+
725
+ /** Real but not in the way; see the note above. */
726
+ const informational = new Set<string>()
727
+ return [
728
+ ...hookChecks(deps).map((check) => ({
729
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
730
+ title: checkTitle(check.name),
731
+ status: check.ok
732
+ ? 'ready' as const
733
+ : check.informational === true || informational.has(check.name)
734
+ ? 'optional-gap' as const
735
+ : 'gap' as const,
736
+ detail: check.detail,
737
+ ...(check.ok
738
+ ? {}
739
+ : {
740
+ remedy: {
741
+ by: 'user-here' as const,
742
+ // The check's own remedy when it has one; the generic reinstall
743
+ // line was wrong exactly where it mattered (an unfired pointer
744
+ // needs a prompt, not `hooks install`).
745
+ ...(check.remedy ?? {
746
+ summary: 'the detail above names what to change',
747
+ command: 'notifai hooks install',
748
+ }),
749
+ },
750
+ }),
751
+ })),
752
+ settings,
753
+ ]
754
+ }
755
+
756
+
757
+ interface HookCheck {
758
+ name: string
759
+ ok: boolean
760
+ detail: string
761
+ /** Real but not in the way: worth a line, never a blocker. */
762
+ informational?: boolean
763
+ /** A remedy truer than the generic `notifai hooks install`. */
764
+ remedy?: { summary: string; command: string }
765
+ }
766
+
767
+ function hookChecks(deps: CommandDeps): HookCheck[] {
768
+ const checks: HookCheck[] = []
769
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
770
+
771
+ // Not having hooks is a setup someone chose, not a fault: `send` works
772
+ // without them. A setup that cannot work is what deserves to go red.
773
+ if (installations.length === 0) {
774
+ checks.push({
775
+ name: 'hooks',
776
+ ok: true,
777
+ detail: 'not installed (optional) — `notifai hooks install` adds question routing',
778
+ })
779
+ return checks
780
+ }
781
+ checks.push({
782
+ name: 'hooks',
783
+ ok: true,
784
+ detail: installations
785
+ .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
786
+ .join(', '),
787
+ })
788
+
789
+ const wired = new Set(installations.map((installation) => installation.harness))
790
+ const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness))
791
+ if (unwired.length > 0) {
792
+ checks.push({
793
+ name: 'hooks (detected)',
794
+ ok: false,
795
+ informational: true,
796
+ detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
797
+ remedy: {
798
+ summary: 'install hooks for every detected harness',
799
+ command: 'notifai hooks install',
800
+ },
801
+ })
802
+ }
803
+
804
+ const { active, contested } = resolveActiveHarness(
805
+ deps.env,
806
+ deps.cwd,
807
+ (deps.now ?? Date.now)(),
808
+ )
809
+ const activeInstallations =
810
+ active === null
811
+ ? []
812
+ : installations.filter((installation) => installation.harness === active.harness)
813
+ if (active !== null) {
814
+ checks.push({
815
+ name: 'hooks (active harness)',
816
+ ok: activeInstallations.length > 0,
817
+ detail:
818
+ activeInstallations.length > 0
819
+ ? `active ${active.label} session has a matching hook installation`
820
+ : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
821
+ ...(activeInstallations.length > 0
822
+ ? {}
823
+ : {
824
+ remedy: {
825
+ summary: `install hooks for the active ${active.label} session`,
826
+ command: `notifai hooks install --harness ${active.harness}`,
827
+ },
828
+ }),
829
+ })
830
+ if (activeInstallations.length > 0) {
831
+ const pointer =
832
+ active.sessionId === undefined
833
+ ? null
834
+ : readMatchingProjectSessionPointer(
835
+ deps.cwd,
836
+ deps.env,
837
+ (deps.now ?? Date.now)(),
838
+ active.sessionId,
839
+ active.harness,
840
+ )
841
+ if (pointer === null) {
842
+ // The normal condition of hooks installed moments ago: the pointer
843
+ // appears when the harness next fires a hook, and no command can
844
+ // force that. Informational, so `init` walks on to the states it can
845
+ // actually prove instead of exiting over evidence only time produces.
846
+ checks.push({
847
+ name: 'hooks (active session)',
848
+ ok: false,
849
+ informational: true,
850
+ detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
851
+ remedy: {
852
+ summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
853
+ command: 'notifai doctor',
854
+ },
855
+ })
856
+ } else {
857
+ checks.push({
858
+ name: 'hooks (active session)',
859
+ ok: true,
860
+ detail: `the concurrent project index contains the active ${active.label} session`,
861
+ })
862
+ }
863
+ }
864
+ }
865
+
866
+ // A handler naming an event this build dropped exits 2 every time the harness
867
+ // fires it, which the harness reports as a hook failure.
868
+ const stale = installations.flatMap((i) =>
869
+ i.handlers
870
+ .filter((h) => {
871
+ const event = handlerEvent(h.command)
872
+ return event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)
873
+ })
874
+ .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`),
875
+ )
876
+ checks.push({
877
+ name: 'hooks (stale)',
878
+ ok: stale.length === 0,
879
+ detail:
880
+ stale.length === 0
881
+ ? 'every installed handler names an event this build serves'
882
+ : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
883
+ })
884
+
885
+ const adapterProblems = installations.flatMap((installation) =>
886
+ (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
887
+ )
888
+ const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform)
889
+ adapterProblems.push(...sharedAdapter.problems)
890
+ if (adapterProblems.length > 0) {
891
+ checks.push({
892
+ name: 'hooks (adapter)',
893
+ ok: false,
894
+ // A machine-global install for a harness that is not active in this
895
+ // project is useful diagnosis, but it must not block unrelated init.
896
+ informational: active === null && installations.every((installation) => installation.global),
897
+ detail: adapterProblems.join('; '),
898
+ })
899
+ }
900
+
901
+ const trustProblems = codexTrustProblems(installations, deps.env)
902
+ checks.push({
903
+ name: 'hooks (trust)',
904
+ ok: trustProblems.length === 0,
905
+ detail:
906
+ trustProblems.length === 0
907
+ ? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
908
+ : `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
909
+ ...(trustProblems.length === 0
910
+ ? {}
911
+ : {
912
+ remedy: {
913
+ summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
914
+ command: '/hooks',
915
+ },
916
+ }),
917
+ })
918
+
919
+ const shapeProblems = installations.flatMap((installation) =>
920
+ stopShapeProblems(installation, deps.hookPlatform).map(
921
+ (problem) =>
922
+ `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
923
+ ),
924
+ )
925
+ checks.push({
926
+ name: 'hooks (stop shape)',
927
+ ok: shapeProblems.length === 0,
928
+ detail:
929
+ shapeProblems.length === 0
930
+ ? `every installed Stop handler declares the shape its harness needs: Claude Code async with an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s waiter budget, blocking hosts ${BLOCKING_STOP_TIMEOUT_SECONDS}s, Codex host-owned`
931
+ : shapeProblems.join('; '),
932
+ })
933
+
934
+ // Project and global definitions for one harness both fire. Stable adapter
935
+ // identity deliberately makes their command bytes equal, so comparing
936
+ // command targets would now hide this duplicate rather than diagnose it.
937
+ // Different harnesses remain independent: only the active one runs.
938
+ const duplicated = [...new Set(installations.map((i) => i.harness))]
939
+ .map((harness) => ({
940
+ harness,
941
+ installations: installations.filter((i) => i.harness === harness),
942
+ }))
943
+ .filter(
944
+ (entry) =>
945
+ entry.installations.some((installation) => installation.global) &&
946
+ entry.installations.some((installation) => !installation.global),
947
+ )
948
+ if (duplicated.length > 0) {
949
+ checks.push({
950
+ name: 'hooks (duplicates)',
951
+ ok: false,
952
+ detail: duplicated
953
+ .map(
954
+ (entry) =>
955
+ `${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(', ')}`,
956
+ )
957
+ .join('; '),
958
+ })
959
+ }
960
+
961
+ const representationProblems = codexRepresentationProblems(
962
+ deps.cwd,
963
+ deps.env,
964
+ deps.hookPlatform,
965
+ )
966
+ if (representationProblems.length > 0) {
967
+ checks.push({
968
+ name: 'hooks (codex representation)',
969
+ ok: false,
970
+ detail: representationProblems.join('; '),
971
+ })
972
+ } else {
973
+ const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform)
974
+ if (coexistence.length > 0) {
975
+ checks.push({
976
+ name: 'hooks (codex representation)',
977
+ ok: true,
978
+ detail: coexistence.join('; '),
979
+ })
980
+ }
981
+ }
982
+
983
+ const codexHome = codexHomeNote(deps.env, deps.hookPlatform)
984
+ if (codexHome !== null) {
985
+ checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome })
986
+ }
987
+
988
+ if (active !== null && activeInstallations.length > 0) {
989
+ const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
990
+ checks.push({
991
+ name: 'hooks (question admission)',
992
+ ok: admissionProblems.length === 0,
993
+ detail:
994
+ admissionProblems.length === 0
995
+ ? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
996
+ : admissionProblems.join('; '),
997
+ })
998
+ }
999
+
1000
+ // Which route this judges, and which remedy it prints, must both belong to
1001
+ // the harness that is actually running here. A machine-global installation
1002
+ // matches every directory, so picking whichever installation matched — or
1003
+ // whichever session last wrote a pointer — reports on a harness the agent
1004
+ // cannot influence: it is told to send a prompt in a harness that is not
1005
+ // running, follows the fail-closed rule, and can never clear the check.
1006
+ const firedPointer =
1007
+ active === null
1008
+ ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
1009
+ : active.sessionId === undefined
1010
+ ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find(
1011
+ (pointer) => pointer.harness === active.harness,
1012
+ ) ?? null)
1013
+ : readMatchingProjectSessionPointer(
1014
+ deps.cwd,
1015
+ deps.env,
1016
+ (deps.now ?? Date.now)(),
1017
+ active.sessionId,
1018
+ active.harness,
1019
+ )
1020
+ const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
1021
+ const promptFired = firedState?.last_prompt_at !== undefined
1022
+ const stopFired = firedState?.last_stop_at !== undefined
1023
+ const fired = firedPointer !== null && promptFired && stopFired
1024
+ // Installations for other harnesses are irrelevant to the active one, and an
1025
+ // active harness with none of its own has nothing to activate: say that
1026
+ // instead of advising a prompt in some other harness. When the environment
1027
+ // is contested and nothing has fired, every candidate is still possible, so
1028
+ // the advice covers all of them rather than betting on one.
1029
+ const activationHarnesses = new Set(
1030
+ contested.length > 1
1031
+ ? contested.map((candidate) => candidate.harness)
1032
+ : active === null
1033
+ ? installations.map((installation) => installation.harness)
1034
+ : [active.harness],
1035
+ )
1036
+ const activationInstallations = installations.filter((installation) =>
1037
+ activationHarnesses.has(installation.harness),
1038
+ )
1039
+ const activationAdvice =
1040
+ activationInstallations.length > 0
1041
+ ? hookActivationAdvice(activationInstallations)
1042
+ : active === null
1043
+ ? hookActivationAdvice(installations)
1044
+ : `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`
1045
+ checks.push({
1046
+ name: 'hooks (fired)',
1047
+ ok: fired,
1048
+ // A wholly fresh install is informational. Once UserPromptSubmit has fired,
1049
+ // a missing Stop is a broken route, not missing historical evidence.
1050
+ informational: firedPointer === null,
1051
+ detail: fired
1052
+ ? active === null
1053
+ ? 'a session in this directory has run UserPromptSubmit and Stop'
1054
+ : `the active ${active.label} session has run UserPromptSubmit and Stop`
1055
+ : firedPointer === null
1056
+ ? `no ${
1057
+ contested.length > 1
1058
+ ? `session pointer for any harness whose markers are present here (${contested
1059
+ .map((candidate) => candidate.label)
1060
+ .join(', ')})`
1061
+ : active === null
1062
+ ? 'session pointer'
1063
+ : `${active.label} session pointer`
1064
+ } from the last 24 hours — ${activationAdvice}`
1065
+ : `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`,
1066
+ ...(fired
1067
+ ? {}
1068
+ : {
1069
+ remedy: {
1070
+ summary:
1071
+ firedPointer === null
1072
+ ? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
1073
+ : 'end one harmless turn, send a new prompt, then re-check',
1074
+ command: 'notifai doctor',
1075
+ },
1076
+ }),
1077
+ })
1078
+
1079
+ const continuationInstallations =
1080
+ active !== null ? activeInstallations : installations
1081
+ const continuationHarnesses = [
1082
+ ...new Set(continuationInstallations.map((installation) => installation.harness)),
1083
+ ]
1084
+ checks.push({
1085
+ name: 'hooks (answer continuation)',
1086
+ ok:
1087
+ continuationHarnesses.length > 0 &&
1088
+ continuationHarnesses.every(
1089
+ (harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
1090
+ ),
1091
+ informational: active === null,
1092
+ detail:
1093
+ continuationHarnesses.length === 0
1094
+ ? active === null
1095
+ ? 'no installed harness route to assess'
1096
+ : `the active ${active.label} session has no matching continuation adapter`
1097
+ : continuationHarnesses
1098
+ .map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
1099
+ .join('; '),
1100
+ })
1101
+
1102
+ const wakeRoute = wakeRouteCheck(deps, active, activeInstallations)
1103
+ if (wakeRoute !== null) checks.push(wakeRoute)
1104
+
1105
+ const stray = codexStrayWorktreeCheck(deps)
1106
+ if (stray !== null) checks.push(stray)
1107
+
1108
+ return checks
1109
+ }
1110
+
1111
+ /**
1112
+ * Whether an answer arriving after this turn ends could actually reach this
1113
+ * exact session — the question `notifai ask` really turns on, and the one no
1114
+ * other check answers.
1115
+ *
1116
+ * Read-only, and deliberately so: nothing here connects to a socket, takes a
1117
+ * lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
1118
+ * would be its own bug report.
1119
+ *
1120
+ * Everything it can report negatively is a degradation rather than a failure —
1121
+ * the accepted journal still replays the answer at the session's next turn —
1122
+ * so this is never a blocker. What it buys is that the reason has a name
1123
+ * before the user notices the silence.
1124
+ *
1125
+ * It never asks for `crossSessionInbound`. The poster is the session's own
1126
+ * hook child and takes the privileged own-child path, verified to be delivered
1127
+ * even against a `bypassPermissions` receiver while an unrelated process was
1128
+ * held. Widening a user's general inbound policy to suit Notifai would be a
1129
+ * real change to their machine's posture in exchange for nothing.
1130
+ */
1131
+ function wakeRouteCheck(
1132
+ deps: CommandDeps,
1133
+ active: ActiveHarnessSession | null,
1134
+ activeInstallations: Installation[],
1135
+ ): HookCheck | null {
1136
+ if (active === null || activeInstallations.length === 0) return null
1137
+ if (active.harness === 'claude-code') {
1138
+ const readiness = inspectClaudeInbox({
1139
+ pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
1140
+ platform: deps.hookPlatform ?? process.platform,
1141
+ readDescriptor:
1142
+ deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
1143
+ socketExists: (socketPath) => existsSync(socketPath),
1144
+ })
1145
+ return {
1146
+ name: 'hooks (wake route)',
1147
+ ok: readiness.state === 'ready',
1148
+ informational: true,
1149
+ detail:
1150
+ readiness.state === 'ready'
1151
+ ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
1152
+ : `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
1153
+ }
1154
+ }
1155
+ if (active.harness === 'codex') {
1156
+ const readiness = inspectCodexResume(deps.env, {
1157
+ platform: deps.hookPlatform ?? process.platform,
1158
+ directoryExists: (directory) => existsSync(directory),
1159
+ })
1160
+ return {
1161
+ name: 'hooks (wake route)',
1162
+ ok: readiness.state === 'ready',
1163
+ informational: true,
1164
+ detail:
1165
+ readiness.state === 'ready'
1166
+ ? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
1167
+ : `the held Codex turn still continues from its own hook, but after it returns nothing can be resumed: ${readiness.reason}. Answers wait for the next turn`,
1168
+ }
1169
+ }
1170
+ return null
1171
+ }
1172
+
1173
+ /**
1174
+ * A Codex hooks file sitting in a worktree, which Codex will never read.
1175
+ *
1176
+ * `settingsFile` now writes to the main repository, so this only fires for a
1177
+ * file an older build left behind — but that file is indistinguishable from a
1178
+ * working install if you go looking, and it is exactly what made this bug take
1179
+ * a day to find. Omitted entirely when there is nothing to say.
1180
+ */
1181
+ function codexStrayWorktreeCheck(
1182
+ deps: CommandDeps,
1183
+ ): { name: string; ok: boolean; detail: string } | null {
1184
+ const layer = codexLayerDir(deps.cwd)
1185
+ if (layer === null) return null
1186
+ const root = codexProjectRoot(deps.cwd)
1187
+ const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env))
1188
+ if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0) return null
1189
+ const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json')
1190
+ const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml')
1191
+ const problems: string[] = []
1192
+ if (!existsSync(layer)) {
1193
+ problems.push(`${layer} is missing, so Codex never looks for project hooks here`)
1194
+ }
1195
+ if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
1196
+ problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`)
1197
+ }
1198
+ if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
1199
+ problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`)
1200
+ }
1201
+ return {
1202
+ name: 'hooks (codex worktree)',
1203
+ ok: problems.length === 0,
1204
+ detail:
1205
+ problems.length === 0
1206
+ ? `worktree wired to the main repository at ${root}`
1207
+ : `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
1208
+ }
1209
+ }