@raidiant/notifai 10.1.1 → 10.1.3

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
@@ -1,1501 +0,0 @@
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 {
9
- HARNESS_CAPABILITIES,
10
- HARNESS_LABELS,
11
- HERMES_QUESTION_ROUTING_UNAVAILABLE,
12
- isHookInstallableHarness,
13
- } from './harnesses.js'
14
- import {
15
- hookAdapterTargetsArtifact,
16
- inspectHookAdapter,
17
- isNpxAdapterTarget,
18
- } from './hook-adapter.js'
19
- import {
20
- readLiveProjectSessionPointers,
21
- readProjectSessionPointer,
22
- readSessionState,
23
- } from './hooks.js'
24
- import {
25
- NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
26
- QUESTION_STOP_TIMEOUT_SECONDS,
27
- codexCoexistenceNotes,
28
- codexHomeNote,
29
- codexLayerDir,
30
- codexLayerPaths,
31
- codexProjectRoot,
32
- codexRepresentationProblems,
33
- codexTrustProblems,
34
- detectedHarnesses,
35
- findInstallations,
36
- handlerEvent,
37
- inspectCodexLayer,
38
- type Installation,
39
- } from './install-hooks.js'
40
- import { inferInvocationContext } from './invocation-context.js'
41
- import type { SkillScope } from './native-skills.js'
42
- import {
43
- firstBlocker,
44
- readinessJson,
45
- type Readiness,
46
- type ReadinessRefresh,
47
- type ReadinessState,
48
- type StateStatus,
49
- } from './readiness.js'
50
- import { packageVersion } from './release.js'
51
- import type { Tone } from './ui/theme.js'
52
- import type { MachineCredential } from './credentials.js'
53
- import {
54
- EXIT,
55
- SETUP_COMMAND,
56
- diagnoseIgnoredOriginOverride,
57
- loadLoggedConfig,
58
- makeClient,
59
- resolvedBaseUrl,
60
- updateCliCommand,
61
- type CommandDeps,
62
- } from './commands-core.js'
63
- import { deviceInstallRemedy, readyCompanionDevices } from './commands-devices.js'
64
- import { setupAccessUrl } from './setup-destinations.js'
65
- import {
66
- claudeSessionPid,
67
- resolveActiveHarness,
68
- type ActiveHarnessSession,
69
- } from './commands-harness-context.js'
70
- import { stopShapeProblems } from './commands-hook-shape.js'
71
- import {
72
- HOOK_EVENTS,
73
- activeQuestionRouteProblems,
74
- hookActivationAdvice,
75
- requiredHookEvents,
76
- } from './commands-hooks.js'
77
- import { cliBinReadiness, inspectCliInstallations } from './cli-bin.js'
78
- import {
79
- latestPublishedCliVersion,
80
- newerPublishedCli,
81
- shouldConsultCliRegistry,
82
- thisCliVersion,
83
- } from './cli-release.js'
84
- import {
85
- SETUP_PROOF_STALE_MS,
86
- observedCompanionReceipt,
87
- readSetupProof,
88
- setupProofProject,
89
- setupProofIsStale,
90
- setupProofApplies,
91
- } from './commands-setup-proof.js'
92
- import { skillReadiness } from './commands-skill.js'
93
- import { projectBinding, projectEnabled } from './project-enablement.js'
94
-
95
- // ---------------------------------------------------------------------------
96
- // doctor
97
- // ---------------------------------------------------------------------------
98
-
99
- /**
100
- * Server-owned support policy plus every shipped platform document. Artifact
101
- * versions and document integers are structured inventory, never routing or a
102
- * definition of "up to date".
103
- */
104
- async function compatibilityCheck(client: ApiClient, deps: CommandDeps): Promise<ReadinessState> {
105
- try {
106
- const [compatibility, documents] = await Promise.all([
107
- client.compatibility(),
108
- Promise.all(PLATFORMS.map((platform) => client.capabilities(platform))),
109
- ])
110
- const serverCapabilities = new Set(compatibility.server_capabilities)
111
- const cliCapabilityIntersection = {
112
- available: SHIPPED_CLI_CAPABILITIES.filter((capability) => serverCapabilities.has(capability)),
113
- missing_on_server: SHIPPED_CLI_CAPABILITIES.filter(
114
- (capability) => !serverCapabilities.has(capability),
115
- ),
116
- }
117
- const technical = {
118
- local: {
119
- cli_version: packageVersion(),
120
- capabilities: [...SHIPPED_CLI_CAPABILITIES],
121
- },
122
- server: compatibility,
123
- capability_documents: documents.map((document) => ({
124
- platform: document.platform,
125
- schema_version: document.schema_version,
126
- })),
127
- cli_capability_intersection: cliCapabilityIntersection,
128
- }
129
- if (compatibility.cli.state === 'must_update') {
130
- return {
131
- id: 'contract',
132
- title: 'Notifai update',
133
- status: 'gap',
134
- detail: "Notifai can't send notifications until you update.",
135
- technical,
136
- remedy: {
137
- by: 'user-here',
138
- summary: 'update Notifai',
139
- command: updateCliCommand(deps),
140
- },
141
- }
142
- }
143
- if (compatibility.cli.state === 'update_available') {
144
- const scheduled = compatibility.cli.reason === 'sunset_scheduled'
145
- return {
146
- id: 'contract',
147
- title: 'Notifai update',
148
- status: 'optional-gap',
149
- detail: scheduled
150
- ? 'Update Notifai soon to keep sending notifications.'
151
- : 'A newer Notifai is available.',
152
- technical,
153
- remedy: {
154
- by: 'user-here',
155
- summary: 'update Notifai',
156
- command: updateCliCommand(deps),
157
- },
158
- }
159
- }
160
- if (cliCapabilityIntersection.missing_on_server.length > 0) {
161
- // Named capabilities are feature-scoped. A service that has not learned
162
- // this CLI's optional feature yet does not make baseline Notification
163
- // Requests unavailable, even when every document carries the same schema
164
- // integer. Structured output names the gap; the human path stays on the
165
- // local service-update sentence and never invents a version diagnosis.
166
- return {
167
- id: 'contract',
168
- title: 'Notifai update',
169
- status: 'optional-gap',
170
- detail: 'The service is being updated; try again later.',
171
- technical,
172
- remedy: {
173
- by: 'user-here',
174
- summary: 'try again after the service update',
175
- command: 'notifai doctor',
176
- },
177
- }
178
- }
179
- return {
180
- id: 'contract',
181
- title: 'Notifai update',
182
- status: 'ready',
183
- detail: 'Notifai can send notifications.',
184
- technical,
185
- }
186
- } catch (err) {
187
- return {
188
- id: 'contract',
189
- title: 'Notifai update',
190
- status: 'optional-gap',
191
- detail: 'The service is being updated; try again later.',
192
- technical: {
193
- error: err instanceof ApiCallError
194
- ? { code: err.code, status: err.status }
195
- : String(err),
196
- },
197
- remedy: {
198
- by: 'user-here',
199
- summary: 'try again after the service update',
200
- command: 'notifai doctor',
201
- },
202
- }
203
- }
204
- }
205
-
206
- function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
207
- const configured = config.project.value
208
- if (configured !== null) {
209
- return {
210
- id: 'project',
211
- title: 'Project identity',
212
- status: 'ready',
213
- detail: `"${configured}" (${config.project.source})`,
214
- }
215
- }
216
- const inferred = inferInvocationContext(deps.cwd).project
217
- if (inferred !== null) {
218
- return {
219
- id: 'project',
220
- title: 'Project identity',
221
- status: 'optional-gap',
222
- detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
223
- remedy: {
224
- by: 'cli',
225
- summary: 'make the inferred Project identity explicit for every checkout',
226
- command: 'notifai init',
227
- },
228
- }
229
- }
230
- return {
231
- id: 'project',
232
- title: 'Project identity',
233
- status: 'optional-gap',
234
- detail: 'the directory name has no characters a Project identifier can use',
235
- remedy: {
236
- by: 'cli',
237
- summary: 'choose an explicit Project identifier',
238
- command: 'notifai init --project-id my-project',
239
- },
240
- }
241
- }
242
-
243
- function projectEnablementReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
244
- const binding = projectBinding(deps.cwd, deps.env, config.project.value)
245
- if (binding === null) {
246
- return {
247
- id: 'project-enablement',
248
- title: 'Project enablement',
249
- status: 'optional-gap',
250
- detail: 'no Project is available here; lifecycle hooks stay silent',
251
- }
252
- }
253
- if (projectEnabled(binding)) {
254
- return {
255
- id: 'project-enablement',
256
- title: 'Project enablement',
257
- status: 'ready',
258
- detail: `enabled by the User for Project "${binding.project}"`,
259
- }
260
- }
261
- return {
262
- id: 'project-enablement',
263
- title: 'Project enablement',
264
- status: 'optional-gap',
265
- detail: `disabled for Project "${binding.project}"; installed lifecycle hooks will emit no model guidance`,
266
- remedy: {
267
- by: 'cli',
268
- summary: 'enable Notifai lifecycle behavior for this Project',
269
- command: 'notifai project enable',
270
- },
271
- }
272
- }
273
-
274
- function remoteStatesFrom(previous: Readiness): {
275
- credential: ReadinessState
276
- server: ReadinessState
277
- contract: ReadinessState
278
- auth: ReadinessState
279
- devices: ReadinessState
280
- proof: ReadinessState
281
- } | null {
282
- const pick = (id: string): ReadinessState | undefined => previous.states.find((state) => state.id === id)
283
- const credential = pick('credential')
284
- const server = pick('server')
285
- const contract = pick('contract')
286
- const auth = pick('auth')
287
- const devices = pick('devices')
288
- const proof = pick('proof')
289
- if (
290
- credential === undefined ||
291
- server === undefined ||
292
- contract === undefined ||
293
- auth === undefined ||
294
- devices === undefined ||
295
- proof === undefined
296
- ) {
297
- return null
298
- }
299
- return { credential, server, contract, auth, devices, proof }
300
- }
301
-
302
- function remoteInvalidatedByConfig(
303
- previous: Readiness,
304
- config: CliConfig,
305
- credential: MachineCredential | null,
306
- ): boolean {
307
- const origin = resolvedBaseUrl(config, credential)
308
- const server = previous.states.find((state) => state.id === 'server')
309
- return server === undefined || !server.detail.includes(origin)
310
- }
311
-
312
- function isNoActivePlanError(err: unknown): err is ApiCallError {
313
- return err instanceof ApiCallError && err.code === 'no_active_plan'
314
- }
315
-
316
- /**
317
- * Device listing is grant-gated; access status is not. A paired Account whose
318
- * plan lapsed must not be diagnosed as a revoked machine.
319
- */
320
- async function probeAccount(
321
- client: ApiClient,
322
- credential: MachineCredential,
323
- baseUrl: string,
324
- ): Promise<{
325
- email: string | null
326
- devices: RoutableDevice[] | null
327
- lookupFailed: boolean
328
- auth: ReadinessState
329
- }> {
330
- const [devicesOutcome, accessOutcome, requestOutcome] = await Promise.allSettled([
331
- Promise.resolve().then(() => client.listDevices()),
332
- Promise.resolve().then(() => client.accessStatus()),
333
- Promise.resolve().then(() => client.accessRequest()),
334
- ])
335
- const access = accessOutcome.status === 'fulfilled' ? accessOutcome.value : null
336
- const pendingRequest =
337
- requestOutcome.status === 'fulfilled' ? requestOutcome.value.request : null
338
- const devices = devicesOutcome.status === 'fulfilled' ? devicesOutcome.value.devices : null
339
- const email = access?.email ?? null
340
- const devicesErr = devicesOutcome.status === 'rejected' ? devicesOutcome.reason : null
341
- const noActivePlan =
342
- access?.status === 'no_active_plan' || isNoActivePlanError(devicesErr)
343
-
344
- if (noActivePlan) {
345
- const next =
346
- (isNoActivePlanError(devicesErr) ? devicesErr.nextAction : null) ??
347
- `Open ${setupAccessUrl(baseUrl)} to set up access, then retry.`
348
- const who = email ? ` (${email})` : ''
349
- // Three values, not two. Someone who asked yesterday is waiting on a person,
350
- // not on an action of theirs, and telling them to ask again — every run,
351
- // forever — is the only thing this terminal could get wrong about a wait it
352
- // cannot shorten.
353
- return {
354
- email,
355
- devices: null,
356
- lookupFailed: true,
357
- auth:
358
- pendingRequest === null
359
- ? {
360
- id: 'auth',
361
- title: 'Account',
362
- status: 'gap',
363
- detail: `this account does not have access to Notifai yet${who}`,
364
- remedy: { by: 'user-elsewhere', summary: next },
365
- }
366
- : {
367
- id: 'auth',
368
- title: 'Account',
369
- status: 'gap',
370
- detail: `access requested on ${pendingRequest.requested_at.slice(0, 10)}${who} — waiting to be granted`,
371
- remedy: {
372
- by: 'user-elsewhere',
373
- summary:
374
- 'nothing is needed from you; this resumes on the next run once access is granted',
375
- },
376
- },
377
- }
378
- }
379
-
380
- if (devices !== null) {
381
- return {
382
- email,
383
- devices,
384
- lookupFailed: false,
385
- auth: {
386
- id: 'auth',
387
- title: 'Account',
388
- status: 'ready',
389
- detail: email
390
- ? `machine ${credential.machineId} accepted (${email})`
391
- : `machine ${credential.machineId} accepted`,
392
- },
393
- }
394
- }
395
-
396
- const err = devicesErr ?? (accessOutcome.status === 'rejected' ? accessOutcome.reason : null)
397
- return {
398
- email: null,
399
- devices: null,
400
- lookupFailed: true,
401
- auth: {
402
- id: 'auth',
403
- title: 'Account',
404
- status: 'gap',
405
- detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
406
- remedy: {
407
- by: 'user-here',
408
- summary: 'this machine is no longer recognised; pair it again',
409
- command: SETUP_COMMAND,
410
- interactive: true,
411
- },
412
- },
413
- }
414
- }
415
-
416
- /**
417
- * Read the whole setup once, in dependency order.
418
- *
419
- * Descent stops where a prerequisite is missing: without a credential there is
420
- * nothing to ask the server with, and without a reachable server a contract
421
- * mismatch is unknowable rather than absent. Those downstream states report
422
- * `unknown`, which is the honest answer and keeps a network outage from
423
- * looking like a broken install.
424
- */
425
- export async function assessReadiness(
426
- deps: CommandDeps,
427
- options: {
428
- skillScope?: SkillScope
429
- previous?: Readiness
430
- refresh?: readonly ReadinessRefresh[]
431
- json?: boolean
432
- } = {},
433
- ): Promise<Readiness> {
434
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
435
- const credential = deps.store.load()
436
- if (credential) diagnoseIgnoredOriginOverride(deps.io, config, credential)
437
- const previous = options.previous
438
- const refresh = options.refresh
439
- if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
440
-
441
- const reuseRemote =
442
- previous !== undefined &&
443
- refresh !== undefined &&
444
- !refresh.includes('remote') &&
445
- !remoteInvalidatedByConfig(previous, config, credential)
446
- if (reuseRemote) {
447
- const reused = remoteStatesFrom(previous)
448
- if (reused !== null) {
449
- return {
450
- states: [
451
- cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform),
452
- projectEnablementReadiness(deps, config),
453
- projectReadiness(deps, config),
454
- reused.credential,
455
- reused.server,
456
- reused.contract,
457
- reused.auth,
458
- ...hookStates(deps),
459
- await skillReadiness(deps, options.skillScope),
460
- reused.devices,
461
- reused.proof,
462
- ],
463
- }
464
- }
465
- }
466
-
467
- const states: ReadinessState[] = []
468
- let accountClient: ApiClient | null = null
469
- let accountDevices: RoutableDevice[] | null = null
470
-
471
- states.push(cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform))
472
- states.push(projectEnablementReadiness(deps, config))
473
- states.push(projectReadiness(deps, config))
474
-
475
- states.push(
476
- credential
477
- ? {
478
- id: 'credential',
479
- title: 'This machine',
480
- status: 'ready',
481
- detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
482
- }
483
- : {
484
- id: 'credential',
485
- title: 'This machine',
486
- status: 'gap',
487
- detail: 'not paired with your account',
488
- remedy: {
489
- by: 'user-here',
490
- summary: 'sign in — this opens your browser to approve the machine',
491
- command: SETUP_COMMAND,
492
- interactive: true,
493
- },
494
- },
495
- )
496
-
497
- const baseUrl = resolvedBaseUrl(config, credential)
498
- const anon = makeClient(deps, baseUrl, null)
499
- // A probe that throws is unreachable, not a crash: this runs against a
500
- // half-configured machine by definition, which is where a client that
501
- // cannot even be constructed properly shows up.
502
- let reachable = false
503
- try {
504
- reachable = await anon.health()
505
- } catch {
506
- reachable = false
507
- }
508
- states.push(
509
- reachable
510
- ? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
511
- : {
512
- id: 'server',
513
- title: 'Service',
514
- status: 'gap',
515
- detail: `cannot reach ${baseUrl}`,
516
- remedy: {
517
- by: 'user-here',
518
- summary: 'check your network',
519
- command: 'notifai doctor',
520
- },
521
- },
522
- )
523
-
524
- if (!reachable || !credential) {
525
- states.push({
526
- id: 'contract',
527
- title: 'Notifai update',
528
- status: 'unknown',
529
- detail: !reachable
530
- ? 'Not checked because the service is unreachable.'
531
- : 'Not checked because this machine is not paired.',
532
- })
533
- } else {
534
- const compatibilityClient = makeClient(
535
- deps,
536
- baseUrl,
537
- `Bearer nfm_${credential.machineId}.${credential.secret}`,
538
- )
539
- states.push(await compatibilityCheck(compatibilityClient, deps))
540
- }
541
-
542
- let accountEmail: string | null = null
543
- let accountLookupFailed = false
544
- if (!credential || !reachable) {
545
- const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
546
- states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` })
547
- } else {
548
- const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
549
- accountClient = client
550
- const probed = await probeAccount(client, credential, baseUrl)
551
- accountDevices = probed.devices
552
- accountEmail = probed.email
553
- accountLookupFailed = probed.lookupFailed
554
- states.push(probed.auth)
555
- }
556
-
557
- // Optional setup that works without a companion device must appear before the
558
- // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
559
- // are reachable without a phone.
560
- states.push(...hookStates(deps))
561
- states.push(await skillReadiness(deps, options.skillScope))
562
-
563
- if (!credential || !reachable) {
564
- const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
565
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
566
- } else if (accountLookupFailed || accountDevices === null) {
567
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
568
- } else {
569
- const devices = accountDevices
570
- const companionDevices = devices.filter(
571
- (device) => device.platform === 'ios' || device.platform === 'android',
572
- )
573
- const ready = readyCompanionDevices(companionDevices)
574
- states.push(
575
- ready.length > 0
576
- ? {
577
- id: 'devices',
578
- title: 'Your devices',
579
- status: 'ready',
580
- detail: `${ready.map((d) => `${d.display_name} (${d.platform})`).join(', ')} ready to receive`,
581
- }
582
- : {
583
- id: 'devices',
584
- title: 'Your devices',
585
- status: 'gap',
586
- // The one gap that cannot be closed from this terminal, and the
587
- // likeliest place a first setup is abandoned. Naming which of
588
- // the three sub-states it is matters: "install the app" is
589
- // useless advice to someone who installed it and denied the
590
- // permission prompt. The live bridge is /support on the
591
- // dashboard origin — not a placeholder, and not typed by hand.
592
- detail:
593
- companionDevices.length === 0
594
- ? 'no active Companion App registered yet'
595
- : `${companionDevices.map((d) => `${d.display_name} (${d.platform}, ${d.permission_status})`).join(', ')} — registered but not able to receive`,
596
- remedy: {
597
- by: 'user-elsewhere',
598
- summary: deviceInstallRemedy({
599
- baseUrl,
600
- email: accountEmail,
601
- devices: companionDevices,
602
- }),
603
- },
604
- },
605
- )
606
- }
607
-
608
- states.push(await setupProofState(deps, config, accountClient, accountDevices))
609
- await applyRegistryRecommendation(deps, options.json === true, states)
610
-
611
- return { states }
612
- }
613
-
614
- async function applyRegistryRecommendation(
615
- deps: CommandDeps,
616
- json: boolean,
617
- states: ReadinessState[],
618
- ): Promise<void> {
619
- if (
620
- !shouldConsultCliRegistry({
621
- ...(deps.io.interactive === undefined ? {} : { interactive: deps.io.interactive }),
622
- json,
623
- env: deps.env,
624
- })
625
- ) {
626
- return
627
- }
628
- const latest = await latestPublishedCliVersion(deps.fetchImpl)
629
- const newer = newerPublishedCli(thisCliVersion(), latest)
630
- if (newer === null) return
631
- const contract = states.find((state) => state.id === 'contract')
632
- if (contract === undefined || contract.status === 'gap') return
633
- contract.status = 'optional-gap'
634
- contract.detail = 'A newer Notifai is available.'
635
- contract.remedy = {
636
- by: 'user-here',
637
- summary: 'update Notifai',
638
- command: updateCliCommand(deps),
639
- }
640
- }
641
-
642
- async function setupProofState(
643
- deps: CommandDeps,
644
- config: CliConfig,
645
- client: ApiClient | null,
646
- devices: RoutableDevice[] | null,
647
- ): Promise<ReadinessState> {
648
- if (client === null || devices === null) {
649
- return {
650
- id: 'proof',
651
- title: 'Delivery proof',
652
- status: 'unknown',
653
- detail: 'not checked — account and device readiness must be established first',
654
- }
655
- }
656
-
657
- const companions = readyCompanionDevices(devices)
658
- if (companions.length === 0) {
659
- return {
660
- id: 'proof',
661
- title: 'Delivery proof',
662
- status: 'unknown',
663
- detail: 'not checked — no iPhone or Android Companion App is ready',
664
- }
665
- }
666
-
667
- const project = setupProofProject(deps, config.project.value)
668
- const proof = readSetupProof(deps, project)
669
- const applies = setupProofApplies(proof, project, companions.map((device) => device.device_id))
670
- if (!applies) {
671
- return {
672
- id: 'proof',
673
- title: 'Delivery proof',
674
- status: 'gap',
675
- detail:
676
- "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
677
- remedy: {
678
- by: 'cli',
679
- summary:
680
- "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
681
- command: 'notifai init',
682
- },
683
- }
684
- }
685
-
686
- try {
687
- const snapshot = await client.evidence(proof.request_id)
688
- const observed = observedCompanionReceipt(snapshot, proof.device_id)
689
- if (observed) {
690
- return {
691
- id: 'proof',
692
- title: 'Delivery proof',
693
- status: 'ready',
694
- detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
695
- }
696
- }
697
- const now = (deps.now ?? Date.now)()
698
- if (setupProofIsStale(proof, now)) {
699
- return {
700
- id: 'proof',
701
- title: 'Delivery proof',
702
- status: 'gap',
703
- detail: `${proof.request_id} is older than ${SETUP_PROOF_STALE_MS / 36e5}h with no Companion Receipt; init will send a replacement`,
704
- remedy: {
705
- by: 'cli',
706
- summary: 'replace the stale verification notification',
707
- command: 'notifai init',
708
- },
709
- }
710
- }
711
- return {
712
- id: 'proof',
713
- title: 'Delivery proof',
714
- status: 'gap',
715
- detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
716
- remedy: {
717
- by: 'cli',
718
- summary: 'check the same verification notification again',
719
- command: 'notifai init',
720
- },
721
- }
722
- } catch (err) {
723
- return {
724
- id: 'proof',
725
- title: 'Delivery proof',
726
- status: 'gap',
727
- detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
728
- remedy: {
729
- by: 'cli',
730
- summary: 'retry the existing verification evidence check',
731
- command: 'notifai init',
732
- },
733
- }
734
- }
735
- }
736
-
737
- export async function doctorCommand(
738
- deps: CommandDeps,
739
- flags: { json?: boolean },
740
- options: { readiness?: Readiness } = {},
741
- ): Promise<number> {
742
- const readiness =
743
- options.readiness ?? (await assessReadiness(deps, flags.json === true ? { json: true } : {}))
744
- const blocker = firstBlocker(readiness)
745
- const ok = blocker === null
746
-
747
- // A human fallback exists only at a real terminal. Pipes, harnesses and
748
- // non-TTY callers get the structured agent variant even without --json.
749
- if (flags.json || deps.io.interactive !== true) {
750
- deps.io.out(
751
- JSON.stringify(
752
- { ...readinessJson(readiness), ok, exit_code: ok ? EXIT.ok : EXIT.failed },
753
- null,
754
- 2,
755
- ),
756
- )
757
- return ok ? EXIT.ok : EXIT.failed
758
- }
759
-
760
- const line = (s: ReadinessState) => `${s.title}: ${s.detail}`
761
- await deps.io.intro?.('Notifai doctor')
762
- for (const s of readiness.states) {
763
- // Working software says nothing about versions. Soft/hard update states use
764
- // only the closed sentence and the exact command, never schema/capability
765
- // vocabulary or a server-provided action.
766
- if (s.id === 'contract') {
767
- if (s.status === 'ready' || s.status === 'unknown') continue
768
- deps.io.out(s.detail)
769
- if (
770
- s.remedy?.by !== 'user-elsewhere' &&
771
- s.remedy?.summary === 'update Notifai' &&
772
- s.remedy.command !== undefined
773
- ) {
774
- deps.io.out(s.remedy.command)
775
- }
776
- continue
777
- }
778
- if (deps.io.check) {
779
- await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
780
- } else {
781
- const mark =
782
- s.status === 'gap'
783
- ? 'FAIL'
784
- : s.status === 'unknown'
785
- ? ' ? '
786
- : s.status === 'optional-gap'
787
- ? ' --'
788
- : 'ok '
789
- deps.io.out(`${mark} ${line(s)}`)
790
- }
791
- }
792
- const surfacedUpdate = readiness.states.some(
793
- (state) => state.id === 'contract' && (state.status === 'gap' || state.status === 'optional-gap'),
794
- )
795
- if (blocker !== null && blocker.id !== 'contract') {
796
- await deps.io.outro?.(`Start with: ${remedyLine(blocker)}`)
797
- } else if (!surfacedUpdate) {
798
- await deps.io.outro?.('Everything looks good')
799
- }
800
- return ok ? EXIT.ok : EXIT.failed
801
- }
802
-
803
- /** Readiness status as a report tone. */
804
- function doctorTone(status: StateStatus): Tone {
805
- switch (status) {
806
- case 'ready':
807
- return 'ok'
808
- case 'gap':
809
- return 'bad'
810
- case 'optional-gap':
811
- return 'warn'
812
- case 'unknown':
813
- return 'pending'
814
- }
815
- }
816
-
817
- /** One line telling the reader what to actually do about a state. */
818
- export function remedyLine(state: ReadinessState): string {
819
- const remedy = state.remedy
820
- if (!remedy) return state.detail
821
- if (remedy.by === 'user-elsewhere') return remedy.summary
822
- return remedy.command === undefined
823
- ? remedy.summary
824
- : `${remedy.summary} — run \`${remedy.command}\``
825
- }
826
-
827
- /**
828
- * Whether the hook installation is internally ready, plus evidence that a
829
- * project session has fired it before. This cannot prove future execution or
830
- * end-to-end notification delivery without a live harness and device test.
831
- *
832
- * Every failure mode here was found the expensive way, by spawning a session
833
- * and watching nothing happen: hooks not installed, installed but never fired,
834
- * or left behind by an older build that named events this one does not serve.
835
- */
836
- /**
837
- * Hook diagnostics as readiness states.
838
- *
839
- * A thin adapter over `hookChecks`, whose every branch was found the expensive
840
- * way and is not worth re-deriving. The judgment added here is which failures
841
- * actually stand in the way.
842
- *
843
- * `doctor` is a report and keeps its strict verdict here: a Codex Stop handler
844
- * that would swallow an answer is a real failure and reads as one. `init` is
845
- * the other consumer, and it treats every state in this group as a report line
846
- * rather than a blocker — Question Routing is optional automation, and nothing
847
- * optional may stand between a send-capable setup and its delivery proof.
848
- *
849
- * `reportOnly` marks the checks that no command can close, only the passage of
850
- * a turn. Those are report lines even in `doctor`, because "look again" is not
851
- * a remedy.
852
- */
853
- function hookStates(deps: CommandDeps): ReadinessState[] {
854
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
855
- const { active, contested } = resolveActiveHarness(
856
- deps.env,
857
- deps.cwd,
858
- (deps.now ?? Date.now)(),
859
- )
860
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
861
- const settings: ReadinessState = {
862
- id: 'question-routing-settings',
863
- title: 'Question routing settings',
864
- status: config.ask_notifications.value ? 'ready' : 'gap',
865
- detail: [
866
- `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
867
- `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
868
- ].join(', '),
869
- ...(config.ask_notifications.value
870
- ? {}
871
- : {
872
- remedy: {
873
- by: 'cli' as const,
874
- summary: 'enable asynchronous question routing',
875
- command: 'notifai config set ask_notifications true',
876
- },
877
- }),
878
- }
879
- if (installations.length === 0) {
880
- if (contested.length > 1) {
881
- return [
882
- {
883
- id: 'hooks',
884
- title: 'Question routing',
885
- status: 'optional-gap',
886
- detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
887
- },
888
- settings,
889
- ]
890
- }
891
- if (active !== null && !isHookInstallableHarness(active.harness)) {
892
- return [
893
- {
894
- id: 'hooks',
895
- title: 'Question routing',
896
- status: 'optional-gap',
897
- detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
898
- },
899
- settings,
900
- ]
901
- }
902
- return [
903
- {
904
- id: 'hooks',
905
- title: 'Question routing',
906
- status: active === null ? 'optional-gap' : 'gap',
907
- detail:
908
- active === null
909
- ? 'hooks not installed, so questions stay in the terminal'
910
- : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
911
- remedy: {
912
- by: 'cli',
913
- summary: 'install harness hooks so questions reach your devices when you are away',
914
- command:
915
- active === null
916
- ? 'notifai hooks install'
917
- : `notifai hooks install --harness ${active.harness}`,
918
- },
919
- },
920
- settings,
921
- ]
922
- }
923
-
924
- /**
925
- * A human title per check.
926
- *
927
- * Three checks used to collapse onto "Question routing", so a reader could not
928
- * tell which of them had failed, and the rest fell through to their internal
929
- * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
930
- * thing to branch on; this is only what a person reads.
931
- */
932
- const CHECK_TITLES: Readonly<Record<string, string>> = {
933
- hooks: 'Question routing',
934
- 'hooks (detected)': 'Harnesses detected',
935
- 'hooks (active harness)': 'Routing for this harness',
936
- 'hooks (active session)': 'Routing for this session',
937
- 'hooks (stale)': 'Hook definitions current',
938
- 'hooks (adapter)': 'Hook adapter',
939
- 'hooks (trust)': 'Codex hook trust',
940
- 'hooks (stop shape)': 'Turn-end hook shape',
941
- 'hooks (duplicates)': 'Duplicate hook installs',
942
- 'hooks (codex representation)': 'Codex hook representation',
943
- 'hooks (question admission)': 'Question admission',
944
- 'hooks (fired)': 'Hooks have run here',
945
- 'hooks (answer continuation)': 'How an answer returns',
946
- 'hooks (wake route)': 'Direct wake route',
947
- }
948
-
949
- function checkTitle(name: string): string {
950
- return CHECK_TITLES[name] ?? name
951
- }
952
-
953
- return [
954
- ...hookChecks(deps).map((check) => ({
955
- id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
956
- title: checkTitle(check.name),
957
- status: check.ok
958
- ? ('ready' as const)
959
- : check.reportOnly === true
960
- ? ('optional-gap' as const)
961
- : ('gap' as const),
962
- detail: check.detail,
963
- ...(check.technical === undefined ? {} : { technical: check.technical }),
964
- ...(check.ok
965
- ? {}
966
- : {
967
- remedy: {
968
- // The check's own remedy when it has one; the generic reinstall
969
- // line was wrong exactly where it mattered (an unfired pointer
970
- // needs a prompt, not `hooks install`).
971
- ...(check.remedy ?? {
972
- summary: 'the detail above names what to change',
973
- command: 'notifai hooks install',
974
- }),
975
- by: check.remedy?.by ?? ('user-here' as const),
976
- },
977
- }),
978
- })),
979
- settings,
980
- ]
981
- }
982
-
983
-
984
- interface HookCheck {
985
- /** No command closes this; only a turn that has not happened yet. */
986
- reportOnly?: boolean
987
- name: string
988
- ok: boolean
989
- detail: string
990
- technical?: unknown
991
- /** A remedy truer than the generic `notifai hooks install`. */
992
- remedy?: {
993
- by?: 'cli' | 'user-here'
994
- summary: string
995
- command: string
996
- user_action?: { code: string; harness: string; action: string; message: string }
997
- }
998
- }
999
-
1000
- function hookChecks(deps: CommandDeps): HookCheck[] {
1001
- const checks: HookCheck[] = []
1002
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
1003
-
1004
- // Not having hooks is a setup someone chose, not a fault: `send` works
1005
- // without them. A setup that cannot work is what deserves to go red.
1006
- if (installations.length === 0) {
1007
- checks.push({
1008
- name: 'hooks',
1009
- ok: true,
1010
- detail: 'not installed (optional) — `notifai hooks install` adds question routing',
1011
- })
1012
- return checks
1013
- }
1014
- checks.push({
1015
- name: 'hooks',
1016
- ok: true,
1017
- detail: installations
1018
- .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
1019
- .join(', '),
1020
- })
1021
-
1022
- const wired = new Set(installations.map((installation) => installation.harness))
1023
- const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness))
1024
- if (unwired.length > 0) {
1025
- checks.push({
1026
- name: 'hooks (detected)',
1027
- ok: false,
1028
- reportOnly: true,
1029
- detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
1030
- remedy: {
1031
- summary: 'install hooks for every detected harness',
1032
- command: 'notifai hooks install',
1033
- },
1034
- })
1035
- }
1036
-
1037
- const { active, contested } = resolveActiveHarness(
1038
- deps.env,
1039
- deps.cwd,
1040
- (deps.now ?? Date.now)(),
1041
- )
1042
- const activeInstallations =
1043
- active === null
1044
- ? []
1045
- : installations.filter((installation) => installation.harness === active.harness)
1046
- if (contested.length > 1) {
1047
- checks.push({
1048
- name: 'hooks (active harness)',
1049
- ok: false,
1050
- reportOnly: true,
1051
- detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
1052
- })
1053
- } else if (active !== null && !isHookInstallableHarness(active.harness)) {
1054
- checks.push({
1055
- name: 'hooks (active harness)',
1056
- ok: true,
1057
- reportOnly: true,
1058
- detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
1059
- })
1060
- } else if (active !== null) {
1061
- checks.push({
1062
- name: 'hooks (active harness)',
1063
- ok: activeInstallations.length > 0,
1064
- detail:
1065
- activeInstallations.length > 0
1066
- ? `active ${active.label} session has a matching hook installation`
1067
- : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
1068
- ...(activeInstallations.length > 0
1069
- ? {}
1070
- : {
1071
- remedy: {
1072
- summary: `install hooks for the active ${active.label} session`,
1073
- command: `notifai hooks install --harness ${active.harness}`,
1074
- },
1075
- }),
1076
- })
1077
- if (activeInstallations.length > 0) {
1078
- const exactState = active.sessionId === undefined
1079
- ? null
1080
- : readSessionState(active.sessionId, deps.env)
1081
- const activated = exactState?.harness === active.harness && exactState.last_prompt_at !== undefined
1082
- if (!activated) {
1083
- // The normal condition of hooks installed moments ago: the pointer
1084
- // appears when the harness next fires a hook, and no command can
1085
- // force that. Informational, so `init` walks on to the states it can
1086
- // actually prove instead of exiting over evidence only time produces.
1087
- checks.push({
1088
- name: 'hooks (active session)',
1089
- ok: false,
1090
- reportOnly: true,
1091
- detail: `active ${active.label} session has not published exact lifecycle state — send one ${active.label} prompt, then check again`,
1092
- remedy: {
1093
- summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
1094
- command: 'notifai doctor',
1095
- },
1096
- })
1097
- } else {
1098
- checks.push({
1099
- name: 'hooks (active session)',
1100
- ok: true,
1101
- detail: `the exact active ${active.label} session has published lifecycle state`,
1102
- })
1103
- }
1104
- }
1105
- }
1106
-
1107
- // A handler naming an event this build dropped exits 2 every time the harness
1108
- // fires it, which the harness reports as a hook failure.
1109
- const stale = installations.flatMap((i) =>
1110
- i.handlers
1111
- .filter((h) => {
1112
- const event = handlerEvent(h.command)
1113
- return event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)
1114
- })
1115
- .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`),
1116
- )
1117
- const missing = installations.flatMap((installation) => {
1118
- const required = requiredHookEvents(installation.harness)
1119
- const installed = new Set(
1120
- installation.handlers
1121
- .map((handler) => handlerEvent(handler.command))
1122
- .filter((event): event is string => event !== null),
1123
- )
1124
- const absent = required.filter((event) => !installed.has(event))
1125
- return absent.length === 0
1126
- ? []
1127
- : [`${installation.file} is missing ${absent.join(', ')}`]
1128
- })
1129
- checks.push({
1130
- name: 'hooks (stale)',
1131
- ok: stale.length === 0 && missing.length === 0,
1132
- detail:
1133
- stale.length === 0 && missing.length === 0
1134
- ? 'every installed handler names an event this build serves, and every required lifecycle handler is present'
1135
- : `${[...stale, ...missing].join('; ')} — rerun \`notifai hooks install\` to refresh the complete lifecycle set`,
1136
- })
1137
-
1138
- const adapterProblems = installations.flatMap((installation) =>
1139
- (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
1140
- )
1141
- const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform)
1142
- adapterProblems.push(...sharedAdapter.problems)
1143
- const runningTarget = deps.hookInstallTarget
1144
- const runningArtifact =
1145
- runningTarget !== undefined && !isNpxAdapterTarget(runningTarget)
1146
- ? runningTarget.scriptPath
1147
- : process.argv[1]
1148
- const cliInstallations = inspectCliInstallations(
1149
- deps.env,
1150
- deps.hookPlatform ?? process.platform,
1151
- {
1152
- ...(runningArtifact === undefined ? {} : { runningArtifactPath: runningArtifact }),
1153
- currentVersion: packageVersion(),
1154
- },
1155
- )
1156
- const effectiveArtifact = cliInstallations.effective?.artifact_path ?? null
1157
- const adapterMismatch =
1158
- sharedAdapter.problems.length === 0 &&
1159
- effectiveArtifact !== null &&
1160
- !hookAdapterTargetsArtifact(sharedAdapter.target, effectiveArtifact)
1161
- if (adapterMismatch) {
1162
- adapterProblems.push('the registered CLI does not match the effective notifai command')
1163
- }
1164
- if (adapterProblems.length > 0) {
1165
- checks.push({
1166
- name: 'hooks (adapter)',
1167
- ok: false,
1168
- // A machine-global install for a harness that is not active in this
1169
- // project is useful diagnosis, but it is not this project's failure.
1170
- reportOnly: active === null && installations.every((installation) => installation.global),
1171
- detail: adapterProblems.join('; '),
1172
- ...(adapterMismatch
1173
- ? { technical: { effective_cli_artifact: effectiveArtifact, registered_target: sharedAdapter.target } }
1174
- : {}),
1175
- ...(adapterMismatch && adapterProblems.length === 1
1176
- ? {
1177
- remedy: {
1178
- by: 'cli' as const,
1179
- summary: 'retarget Question Routing to the effective notifai command',
1180
- command: 'notifai init',
1181
- },
1182
- }
1183
- : {}),
1184
- })
1185
- }
1186
-
1187
- const trustProblems = codexTrustProblems(installations, deps.env)
1188
- checks.push({
1189
- name: 'hooks (trust)',
1190
- ok: trustProblems.length === 0,
1191
- detail:
1192
- trustProblems.length === 0
1193
- ? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
1194
- : `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
1195
- ...(trustProblems.length === 0
1196
- ? {}
1197
- : {
1198
- remedy: {
1199
- summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
1200
- command: '/hooks',
1201
- user_action: {
1202
- code: 'codex_hook_approval_required',
1203
- harness: 'codex',
1204
- action: 'approve_or_enable_notifai_hooks',
1205
- message: 'Open `/hooks` in Codex, approve or enable the Notifai handlers, then tell me when it is done. I will finish setup and verify a fresh session.',
1206
- },
1207
- },
1208
- }),
1209
- })
1210
-
1211
- const shapeProblems = installations.flatMap((installation) =>
1212
- stopShapeProblems(installation, deps.hookPlatform).map(
1213
- (problem) =>
1214
- `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
1215
- ),
1216
- )
1217
- checks.push({
1218
- name: 'hooks (stop shape)',
1219
- ok: shapeProblems.length === 0,
1220
- detail:
1221
- shapeProblems.length === 0
1222
- ? `every installed Stop handler declares the shape its harness needs: Claude Code async and Codex blocking with an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s full-window budget; non-routing blocking hosts ${NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS}s`
1223
- : shapeProblems.join('; '),
1224
- })
1225
-
1226
- // Project and global definitions for one harness both fire. Stable adapter
1227
- // identity deliberately makes their command bytes equal, so comparing
1228
- // command targets would now hide this duplicate rather than diagnose it.
1229
- // Cursor is the exception to harness independence because it may also load
1230
- // Claude's user hook file. The shared adapter detects Cursor's hook-only
1231
- // environment and makes that compatibility copy a no-op; the native Cursor
1232
- // definition remains the single routing owner.
1233
- const duplicated = [...new Set(installations.map((i) => i.harness))]
1234
- .map((harness) => ({
1235
- harness,
1236
- installations: installations.filter((i) => i.harness === harness),
1237
- }))
1238
- .filter(
1239
- (entry) =>
1240
- entry.installations.some((installation) => installation.global) &&
1241
- entry.installations.some((installation) => !installation.global),
1242
- )
1243
- if (duplicated.length > 0) {
1244
- checks.push({
1245
- name: 'hooks (duplicates)',
1246
- ok: false,
1247
- detail: duplicated
1248
- .map(
1249
- (entry) =>
1250
- `${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(', ')}`,
1251
- )
1252
- .join('; '),
1253
- })
1254
- }
1255
-
1256
- const representationProblems = codexRepresentationProblems(
1257
- deps.cwd,
1258
- deps.env,
1259
- deps.hookPlatform,
1260
- )
1261
- if (representationProblems.length > 0) {
1262
- checks.push({
1263
- name: 'hooks (codex representation)',
1264
- ok: false,
1265
- detail: representationProblems.join('; '),
1266
- })
1267
- } else {
1268
- const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform)
1269
- if (coexistence.length > 0) {
1270
- checks.push({
1271
- name: 'hooks (codex representation)',
1272
- ok: true,
1273
- detail: coexistence.join('; '),
1274
- })
1275
- }
1276
- }
1277
-
1278
- const codexHome = codexHomeNote(deps.env, deps.hookPlatform)
1279
- if (codexHome !== null) {
1280
- checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome })
1281
- }
1282
-
1283
- if (active !== null && activeInstallations.length > 0) {
1284
- const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
1285
- checks.push({
1286
- name: 'hooks (question admission)',
1287
- ok: admissionProblems.length === 0,
1288
- detail:
1289
- admissionProblems.length === 0
1290
- ? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
1291
- : admissionProblems.join('; '),
1292
- })
1293
- }
1294
-
1295
- // Which route this judges, and which remedy it prints, must both belong to
1296
- // the harness that is actually running here. A machine-global installation
1297
- // matches every directory, so picking whichever installation matched — or
1298
- // whichever session last wrote a pointer — reports on a harness the agent
1299
- // cannot influence: it is told to send a prompt in a harness that is not
1300
- // running, follows the fail-closed rule, and can never clear the check.
1301
- const firedPointer =
1302
- active === null
1303
- ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
1304
- : active.sessionId === undefined
1305
- ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find(
1306
- (pointer) => pointer.harness === active.harness,
1307
- ) ?? null)
1308
- : readSessionState(active.sessionId, deps.env).harness === active.harness
1309
- ? { sessionId: active.sessionId, harness: active.harness }
1310
- : null
1311
- const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
1312
- const promptFired = firedState?.last_prompt_at !== undefined
1313
- const stopFired = firedState?.last_stop_at !== undefined
1314
- const fired = firedPointer !== null && promptFired && stopFired
1315
- // Installations for other harnesses are irrelevant to the active one, and an
1316
- // active harness with none of its own has nothing to activate: say that
1317
- // instead of advising a prompt in some other harness. When the environment
1318
- // is contested and nothing has fired, every candidate is still possible, so
1319
- // the advice covers all of them rather than betting on one.
1320
- const activationHarnesses = new Set(
1321
- contested.length > 1
1322
- ? contested.map((candidate) => candidate.harness)
1323
- : active === null
1324
- ? installations.map((installation) => installation.harness)
1325
- : [active.harness],
1326
- )
1327
- const activationInstallations = installations.filter((installation) =>
1328
- activationHarnesses.has(installation.harness),
1329
- )
1330
- const activationAdvice =
1331
- activationInstallations.length > 0
1332
- ? hookActivationAdvice(activationInstallations)
1333
- : active === null
1334
- ? hookActivationAdvice(installations)
1335
- : `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`
1336
- checks.push({
1337
- name: 'hooks (fired)',
1338
- ok: fired,
1339
- // Nothing here is a command. Whether the pointer is absent or the turn is
1340
- // half-finished, the only thing that closes this is a turn ending — which
1341
- // an agent running `init` inside that very turn cannot supply.
1342
- reportOnly: true,
1343
- detail: fired
1344
- ? active === null
1345
- ? 'a session in this directory has run UserPromptSubmit and Stop'
1346
- : `the active ${active.label} session has run UserPromptSubmit and Stop`
1347
- : firedPointer === null
1348
- ? `no ${
1349
- contested.length > 1
1350
- ? `session pointer for any harness whose markers are present here (${contested
1351
- .map((candidate) => candidate.label)
1352
- .join(', ')})`
1353
- : active === null
1354
- ? 'session pointer'
1355
- : `${active.label} session pointer`
1356
- } from the last 24 hours — ${activationAdvice}`
1357
- : `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`,
1358
- ...(fired
1359
- ? {}
1360
- : {
1361
- remedy: {
1362
- summary:
1363
- firedPointer === null
1364
- ? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
1365
- : 'end one harmless turn, send a new prompt, then re-check',
1366
- command: 'notifai doctor',
1367
- },
1368
- }),
1369
- })
1370
-
1371
- const continuationInstallations =
1372
- active !== null ? activeInstallations : installations
1373
- const continuationHarnesses = [
1374
- ...new Set(continuationInstallations.map((installation) => installation.harness)),
1375
- ]
1376
- checks.push({
1377
- name: 'hooks (answer continuation)',
1378
- ok:
1379
- continuationHarnesses.length > 0 &&
1380
- continuationHarnesses.every(
1381
- (harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
1382
- ),
1383
- reportOnly: active === null,
1384
- detail:
1385
- continuationHarnesses.length === 0
1386
- ? active === null
1387
- ? 'no installed harness route to assess'
1388
- : `the active ${active.label} session has no matching continuation adapter`
1389
- : continuationHarnesses
1390
- .map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
1391
- .join('; '),
1392
- })
1393
-
1394
- const wakeRoute = wakeRouteCheck(deps, active, activeInstallations)
1395
- if (wakeRoute !== null) checks.push(wakeRoute)
1396
-
1397
- const stray = codexStrayWorktreeCheck(deps)
1398
- if (stray !== null) checks.push(stray)
1399
-
1400
- return checks
1401
- }
1402
-
1403
- /**
1404
- * Whether an answer arriving after this turn ends could actually reach this
1405
- * exact session — the question `notifai ask` really turns on, and the one no
1406
- * other check answers.
1407
- *
1408
- * Read-only, and deliberately so: nothing here connects to a socket, takes a
1409
- * lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
1410
- * would be its own bug report.
1411
- *
1412
- * Everything it can report negatively is a degradation rather than a failure —
1413
- * the accepted journal still replays the answer at the session's next turn —
1414
- * so this is never a blocker. What it buys is that the reason has a name
1415
- * before the user notices the silence.
1416
- *
1417
- * It never asks for `crossSessionInbound`. The poster is the session's own
1418
- * hook child and takes the privileged own-child path, verified to be delivered
1419
- * even against a `bypassPermissions` receiver while an unrelated process was
1420
- * held. Widening a user's general inbound policy to suit Notifai would be a
1421
- * real change to their machine's posture in exchange for nothing.
1422
- */
1423
- function wakeRouteCheck(
1424
- deps: CommandDeps,
1425
- active: ActiveHarnessSession | null,
1426
- activeInstallations: Installation[],
1427
- ): HookCheck | null {
1428
- if (active === null || activeInstallations.length === 0) return null
1429
- if (active.harness === 'claude-code') {
1430
- const readiness = inspectClaudeInbox({
1431
- pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
1432
- platform: deps.hookPlatform ?? process.platform,
1433
- readDescriptor:
1434
- deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
1435
- socketExists: (socketPath) => existsSync(socketPath),
1436
- })
1437
- return {
1438
- name: 'hooks (wake route)',
1439
- ok: readiness.state === 'ready',
1440
- reportOnly: true,
1441
- detail:
1442
- readiness.state === 'ready'
1443
- ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
1444
- : `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
1445
- }
1446
- }
1447
- if (active.harness === 'codex') {
1448
- const readiness = inspectCodexResume(deps.env, {
1449
- platform: deps.hookPlatform ?? process.platform,
1450
- directoryExists: (directory) => existsSync(directory),
1451
- })
1452
- return {
1453
- name: 'hooks (wake route)',
1454
- ok: readiness.state === 'ready',
1455
- reportOnly: true,
1456
- detail:
1457
- readiness.state === 'ready'
1458
- ? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
1459
- : `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`,
1460
- }
1461
- }
1462
- return null
1463
- }
1464
-
1465
- /**
1466
- * A Codex hooks file sitting in a worktree, which Codex will never read.
1467
- *
1468
- * `settingsFile` now writes to the main repository, so this only fires for a
1469
- * file an older build left behind — but that file is indistinguishable from a
1470
- * working install if you go looking, and it is exactly what made this bug take
1471
- * a day to find. Omitted entirely when there is nothing to say.
1472
- */
1473
- function codexStrayWorktreeCheck(
1474
- deps: CommandDeps,
1475
- ): { name: string; ok: boolean; detail: string } | null {
1476
- const layer = codexLayerDir(deps.cwd)
1477
- if (layer === null) return null
1478
- const root = codexProjectRoot(deps.cwd)
1479
- const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env))
1480
- if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0) return null
1481
- const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json')
1482
- const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml')
1483
- const problems: string[] = []
1484
- if (!existsSync(layer)) {
1485
- problems.push(`${layer} is missing, so Codex never looks for project hooks here`)
1486
- }
1487
- if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
1488
- problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`)
1489
- }
1490
- if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
1491
- problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`)
1492
- }
1493
- return {
1494
- name: 'hooks (codex worktree)',
1495
- ok: problems.length === 0,
1496
- detail:
1497
- problems.length === 0
1498
- ? `worktree wired to the main repository at ${root}`
1499
- : `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
1500
- }
1501
- }