@raidiant/notifai 10.1.0 → 10.1.1

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 (97) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +8 -1
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +245 -87
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send.d.ts +1 -1
  32. package/dist/commands-send.js +121 -35
  33. package/dist/commands-send.js.map +1 -1
  34. package/dist/commands-update.d.ts +10 -0
  35. package/dist/commands-update.js +141 -0
  36. package/dist/commands-update.js.map +1 -0
  37. package/dist/commands.d.ts +2 -1
  38. package/dist/commands.js +2 -1
  39. package/dist/commands.js.map +1 -1
  40. package/dist/hook-adapter.d.ts +2 -0
  41. package/dist/hook-adapter.js +12 -1
  42. package/dist/hook-adapter.js.map +1 -1
  43. package/dist/hooks.d.ts +43 -2
  44. package/dist/hooks.js +163 -21
  45. package/dist/hooks.js.map +1 -1
  46. package/dist/main-run.d.ts +1 -0
  47. package/dist/main-run.js +42 -0
  48. package/dist/main-run.js.map +1 -0
  49. package/dist/main.js +10 -31
  50. package/dist/main.js.map +1 -1
  51. package/dist/node-floor.d.ts +18 -0
  52. package/dist/node-floor.js +35 -0
  53. package/dist/node-floor.js.map +1 -0
  54. package/dist/program.d.ts +2 -1
  55. package/dist/program.js +22 -5
  56. package/dist/program.js.map +1 -1
  57. package/dist/question-settlement-process.d.ts +15 -0
  58. package/dist/question-settlement-process.js +35 -0
  59. package/dist/question-settlement-process.js.map +1 -0
  60. package/dist/readiness.d.ts +25 -1
  61. package/dist/readiness.js +32 -2
  62. package/dist/readiness.js.map +1 -1
  63. package/dist/setup-destinations.d.ts +23 -0
  64. package/dist/setup-destinations.js +44 -0
  65. package/dist/setup-destinations.js.map +1 -0
  66. package/package.json +2 -2
  67. package/src/cli-bin.test.ts +145 -7
  68. package/src/cli-bin.ts +176 -10
  69. package/src/cli-update-contract.ts +9 -0
  70. package/src/cli-update.test.ts +125 -0
  71. package/src/client.ts +7 -0
  72. package/src/codex-wake.ts +5 -1
  73. package/src/commands-auth.ts +44 -11
  74. package/src/commands-core.ts +18 -4
  75. package/src/commands-devices.ts +23 -16
  76. package/src/commands-doctor.ts +116 -55
  77. package/src/commands-hooks.ts +66 -16
  78. package/src/commands-init.ts +265 -88
  79. package/src/commands-send.ts +145 -39
  80. package/src/commands-update.ts +180 -0
  81. package/src/commands.test.ts +518 -49
  82. package/src/commands.ts +2 -0
  83. package/src/hook-adapter.ts +14 -0
  84. package/src/hooks.test.ts +275 -0
  85. package/src/hooks.ts +270 -18
  86. package/src/main-run.ts +45 -0
  87. package/src/main.ts +9 -33
  88. package/src/node-floor.test.ts +27 -0
  89. package/src/node-floor.ts +37 -0
  90. package/src/program.test.ts +35 -0
  91. package/src/program.ts +28 -4
  92. package/src/question-settlement-process.ts +47 -0
  93. package/src/readiness.ts +35 -2
  94. package/src/setup-destinations.test.ts +41 -0
  95. package/src/setup-destinations.ts +50 -0
  96. package/src/skill.test.ts +11 -0
  97. package/src/commands-devices.test.ts +0 -15
@@ -14,8 +14,10 @@ import { type HookAdapterTarget } from './hook-adapter.js'
14
14
  import { logConfigResolved, logSettingsFrom, nullLogger, type Logger } from './logging.js'
15
15
  import type { NativeSkills } from './native-skills.js'
16
16
  import type { OrcaSessionTitleLookup } from './orca-session-title.js'
17
+ import type { QuestionSettlementLaunch } from './question-settlement-process.js'
17
18
  import { packageVersion } from './release.js'
18
19
  import type { Tone } from './ui/theme.js'
20
+ import { cliUpdateRecoveryCommand } from './cli-update-contract.js'
19
21
 
20
22
  export interface CommandIo {
21
23
  out(line: string): void
@@ -80,6 +82,8 @@ export interface CommandDeps {
80
82
  /** Test seams for the Codex thread-writer probe and cold resume. */
81
83
  codexWake?: CodexWakeAdapters
82
84
  codexSourcePid?: number
85
+ /** Test seam for the detached owner recovered from a pre-Stop User prompt. */
86
+ spawnQuestionSettlement?: (launch: QuestionSettlementLaunch) => void
83
87
  /** Test seam and production adapter for the external native skills installer. */
84
88
  nativeSkills?: NativeSkills
85
89
  /** Test seam for Orca's exact-worktree task/session title lookup. */
@@ -184,7 +188,13 @@ export function authedClient(deps: CommandDeps, config: CliConfig): { client: Ap
184
188
  }
185
189
  }
186
190
 
187
- export const UPDATE_CLI_COMMAND = 'npm install -g @raidiant/notifai'
191
+ /**
192
+ * Resolve a current CLI independently of PATH. A bare `notifai update` would
193
+ * be captured by the stale winner this action is meant to repair.
194
+ */
195
+ export function updateCliCommand(_deps: Pick<CommandDeps, 'hookInstallTarget' | 'hookPlatform'>): string {
196
+ return cliUpdateRecoveryCommand()
197
+ }
188
198
 
189
199
  /** The one first-run command an unsigned machine is told to run. */
190
200
  export const SETUP_COMMAND = 'notifai init'
@@ -202,10 +212,14 @@ function naturalList(values: readonly string[]): string {
202
212
  return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`
203
213
  }
204
214
 
205
- function localRecovery(action: RecoveryAction | null, details: unknown): string | null {
215
+ function localRecovery(
216
+ deps: Pick<CommandDeps, 'hookInstallTarget' | 'hookPlatform'>,
217
+ action: RecoveryAction | null,
218
+ details: unknown,
219
+ ): string | null {
206
220
  switch (action) {
207
221
  case 'update_cli':
208
- return `next: ${UPDATE_CLI_COMMAND}`
222
+ return `next: ${updateCliCommand(deps)}`
209
223
  case 'update_companion': {
210
224
  const names = affectedDeviceNames(details)
211
225
  return names.length > 0
@@ -271,7 +285,7 @@ export function reportError(
271
285
  const recovery =
272
286
  err.code === 'no_active_devices'
273
287
  ? 'next: Run `notifai init` to connect an active Companion App; it will complete machine setup and identify any human-only device step.'
274
- : localRecovery(err.recoveryAction, err.details)
288
+ : localRecovery(deps, err.recoveryAction, err.details)
275
289
  if (recovery !== null) deps.io.err(recovery)
276
290
  else if (err.recoveryAction === null && err.nextAction) deps.io.err(`next: ${err.nextAction}`)
277
291
  if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
@@ -8,6 +8,11 @@ import {
8
8
  resolvedBaseUrl,
9
9
  type CommandDeps,
10
10
  } from './commands-core.js'
11
+ import {
12
+ companionPlatformLabel,
13
+ setupCompanionUrl,
14
+ type CompanionPlatform,
15
+ } from './setup-destinations.js'
11
16
 
12
17
  // ---------------------------------------------------------------------------
13
18
  // devices / capabilities
@@ -40,15 +45,15 @@ export async function devicesCommand(
40
45
  deps.io.out(`No ${platform} devices registered.`)
41
46
  return EXIT.ok
42
47
  }
43
- const supportUrl = supportPageUrl(authed.baseUrl)
48
+ const setupUrl = setupCompanionUrl(authed.baseUrl)
44
49
  let email: string | null = null
45
50
  try {
46
51
  email = (await authed.client.accessStatus()).email
47
52
  } catch {
48
- // Best-effort: the empty-state copy still points at /support without it.
53
+ // Best-effort: the empty-state copy still names the destination.
49
54
  }
50
55
  deps.io.out(
51
- `No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
56
+ `No devices yet. Set up a Companion App at ${setupUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
52
57
  )
53
58
  return EXIT.ok
54
59
  }
@@ -120,14 +125,6 @@ export async function capabilitiesCommand(
120
125
  }
121
126
  }
122
127
 
123
- export function supportPageUrl(baseUrl: string): string {
124
- const normalized = baseUrl.replace(/\/+$/, '')
125
- if (normalized === 'https://api.notifai.sh' || normalized === 'https://notifai.fly.dev') {
126
- return 'https://app.notifai.sh/support'
127
- }
128
- return `${normalized}/support`
129
- }
130
-
131
128
  /** Same-account line for the device hop; prefers the real email when known. */
132
129
  export function sameAccountSignInLine(email: string | null | undefined): string {
133
130
  return email
@@ -135,18 +132,28 @@ export function sameAccountSignInLine(email: string | null | undefined): string
135
132
  : 'sign in with the same email as this account'
136
133
  }
137
134
 
135
+ /**
136
+ * What is actually left to do on the phone.
137
+ *
138
+ * Three sub-states, three remedies. "Install the app" is useless advice to
139
+ * someone who installed it and then declined the permission prompt, and
140
+ * offering install steps to a registered device says this terminal did not
141
+ * read the state it just fetched.
142
+ */
138
143
  export function deviceInstallRemedy(options: {
139
144
  baseUrl: string
140
145
  email: string | null
141
146
  devices: readonly RoutableDevice[]
147
+ platform?: CompanionPlatform
142
148
  }): string {
143
- const support = supportPageUrl(options.baseUrl)
144
149
  const sameEmail = sameAccountSignInLine(options.email)
145
150
  if (options.devices.length === 0) {
146
- return (
147
- `open the Companion App install steps at ${support} on a supported device, ` +
148
- `install Notifai, ${sameEmail}, and allow notifications`
149
- )
151
+ const destination = setupCompanionUrl(options.baseUrl, options.platform)
152
+ const named =
153
+ options.platform === undefined
154
+ ? 'the Companion App'
155
+ : `Notifai on ${companionPlatformLabel(options.platform)}`
156
+ return `open the steps for ${named} at ${destination}, install it, ${sameEmail}, and allow notifications`
150
157
  }
151
158
  if (options.devices.some((d) => d.permission_status === 'denied')) {
152
159
  return "allow notifications for Notifai in the device's Settings"
@@ -11,7 +11,11 @@ import {
11
11
  HERMES_QUESTION_ROUTING_UNAVAILABLE,
12
12
  isHookInstallableHarness,
13
13
  } from './harnesses.js'
14
- import { inspectHookAdapter } from './hook-adapter.js'
14
+ import {
15
+ hookAdapterTargetsArtifact,
16
+ inspectHookAdapter,
17
+ isNpxAdapterTarget,
18
+ } from './hook-adapter.js'
15
19
  import {
16
20
  readLiveProjectSessionPointers,
17
21
  readProjectSessionPointer,
@@ -49,14 +53,15 @@ import type { MachineCredential } from './credentials.js'
49
53
  import {
50
54
  EXIT,
51
55
  SETUP_COMMAND,
52
- UPDATE_CLI_COMMAND,
53
56
  diagnoseIgnoredOriginOverride,
54
57
  loadLoggedConfig,
55
58
  makeClient,
56
59
  resolvedBaseUrl,
60
+ updateCliCommand,
57
61
  type CommandDeps,
58
62
  } from './commands-core.js'
59
- import { deviceInstallRemedy, readyCompanionDevices, supportPageUrl } from './commands-devices.js'
63
+ import { deviceInstallRemedy, readyCompanionDevices } from './commands-devices.js'
64
+ import { setupAccessUrl } from './setup-destinations.js'
60
65
  import {
61
66
  claudeSessionPid,
62
67
  resolveActiveHarness,
@@ -69,7 +74,7 @@ import {
69
74
  hookActivationAdvice,
70
75
  requiredHookEvents,
71
76
  } from './commands-hooks.js'
72
- import { cliBinReadiness } from './cli-bin.js'
77
+ import { cliBinReadiness, inspectCliInstallations } from './cli-bin.js'
73
78
  import {
74
79
  latestPublishedCliVersion,
75
80
  newerPublishedCli,
@@ -96,7 +101,7 @@ import { projectBinding, projectEnabled } from './project-enablement.js'
96
101
  * versions and document integers are structured inventory, never routing or a
97
102
  * definition of "up to date".
98
103
  */
99
- async function compatibilityCheck(client: ApiClient): Promise<ReadinessState> {
104
+ async function compatibilityCheck(client: ApiClient, deps: CommandDeps): Promise<ReadinessState> {
100
105
  try {
101
106
  const [compatibility, documents] = await Promise.all([
102
107
  client.compatibility(),
@@ -131,7 +136,7 @@ async function compatibilityCheck(client: ApiClient): Promise<ReadinessState> {
131
136
  remedy: {
132
137
  by: 'user-here',
133
138
  summary: 'update Notifai',
134
- command: UPDATE_CLI_COMMAND,
139
+ command: updateCliCommand(deps),
135
140
  },
136
141
  }
137
142
  }
@@ -148,7 +153,7 @@ async function compatibilityCheck(client: ApiClient): Promise<ReadinessState> {
148
153
  remedy: {
149
154
  by: 'user-here',
150
155
  summary: 'update Notifai',
151
- command: UPDATE_CLI_COMMAND,
156
+ command: updateCliCommand(deps),
152
157
  },
153
158
  }
154
159
  }
@@ -322,11 +327,14 @@ async function probeAccount(
322
327
  lookupFailed: boolean
323
328
  auth: ReadinessState
324
329
  }> {
325
- const [devicesOutcome, accessOutcome] = await Promise.allSettled([
330
+ const [devicesOutcome, accessOutcome, requestOutcome] = await Promise.allSettled([
326
331
  Promise.resolve().then(() => client.listDevices()),
327
332
  Promise.resolve().then(() => client.accessStatus()),
333
+ Promise.resolve().then(() => client.accessRequest()),
328
334
  ])
329
335
  const access = accessOutcome.status === 'fulfilled' ? accessOutcome.value : null
336
+ const pendingRequest =
337
+ requestOutcome.status === 'fulfilled' ? requestOutcome.value.request : null
330
338
  const devices = devicesOutcome.status === 'fulfilled' ? devicesOutcome.value.devices : null
331
339
  const email = access?.email ?? null
332
340
  const devicesErr = devicesOutcome.status === 'rejected' ? devicesOutcome.reason : null
@@ -336,23 +344,36 @@ async function probeAccount(
336
344
  if (noActivePlan) {
337
345
  const next =
338
346
  (isNoActivePlanError(devicesErr) ? devicesErr.nextAction : null) ??
339
- `Open ${supportPageUrl(baseUrl)} to request Alpha access, then retry.`
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.
340
353
  return {
341
354
  email,
342
355
  devices: null,
343
356
  lookupFailed: true,
344
- auth: {
345
- id: 'auth',
346
- title: 'Account',
347
- status: 'gap',
348
- detail: email
349
- ? `no active plan or temporary Alpha access (${email})`
350
- : 'no active plan or temporary Alpha access',
351
- remedy: {
352
- by: 'user-elsewhere',
353
- summary: next,
354
- },
355
- },
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
+ },
356
377
  }
357
378
  }
358
379
 
@@ -515,7 +536,7 @@ export async function assessReadiness(
515
536
  baseUrl,
516
537
  `Bearer nfm_${credential.machineId}.${credential.secret}`,
517
538
  )
518
- states.push(await compatibilityCheck(compatibilityClient))
539
+ states.push(await compatibilityCheck(compatibilityClient, deps))
519
540
  }
520
541
 
521
542
  let accountEmail: string | null = null
@@ -570,7 +591,7 @@ export async function assessReadiness(
570
591
  // dashboard origin — not a placeholder, and not typed by hand.
571
592
  detail:
572
593
  companionDevices.length === 0
573
- ? `no active Companion device registered yet; install Notifai via ${supportPageUrl(baseUrl)}`
594
+ ? 'no active Companion App registered yet'
574
595
  : `${companionDevices.map((d) => `${d.display_name} (${d.platform}, ${d.permission_status})`).join(', ')} — registered but not able to receive`,
575
596
  remedy: {
576
597
  by: 'user-elsewhere',
@@ -614,7 +635,7 @@ async function applyRegistryRecommendation(
614
635
  contract.remedy = {
615
636
  by: 'user-here',
616
637
  summary: 'update Notifai',
617
- command: UPDATE_CLI_COMMAND,
638
+ command: updateCliCommand(deps),
618
639
  }
619
640
  }
620
641
 
@@ -745,8 +766,12 @@ export async function doctorCommand(
745
766
  if (s.id === 'contract') {
746
767
  if (s.status === 'ready' || s.status === 'unknown') continue
747
768
  deps.io.out(s.detail)
748
- if (s.remedy?.by !== 'user-elsewhere' && s.remedy?.command === UPDATE_CLI_COMMAND) {
749
- deps.io.out(UPDATE_CLI_COMMAND)
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)
750
775
  }
751
776
  continue
752
777
  }
@@ -815,14 +840,15 @@ export function remedyLine(state: ReadinessState): string {
815
840
  * way and is not worth re-deriving. The judgment added here is which failures
816
841
  * actually stand in the way.
817
842
  *
818
- * Not everything failed is in the way. A pointer that has never been
819
- * published is the normal condition of an install thirty seconds old — the
820
- * next prompt fixes it and no command can. Treating that as blocking would
821
- * mean `init` could only finish after a session had already run — so it
822
- * reports as something worth knowing rather than something to
823
- * fix, and `init` walks on to the states it can actually close, delivery
824
- * proof included. Which failures are informational, and what the true remedy
825
- * is, is each check's own call (`HookCheck`).
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.
826
852
  */
827
853
  function hookStates(deps: CommandDeps): ReadinessState[] {
828
854
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
@@ -924,23 +950,21 @@ function checkTitle(name: string): string {
924
950
  return CHECK_TITLES[name] ?? name
925
951
  }
926
952
 
927
- /** Real but not in the way; see the note above. */
928
- const informational = new Set<string>()
929
953
  return [
930
954
  ...hookChecks(deps).map((check) => ({
931
955
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
932
956
  title: checkTitle(check.name),
933
957
  status: check.ok
934
- ? 'ready' as const
935
- : check.informational === true || informational.has(check.name)
936
- ? 'optional-gap' as const
937
- : 'gap' as const,
958
+ ? ('ready' as const)
959
+ : check.reportOnly === true
960
+ ? ('optional-gap' as const)
961
+ : ('gap' as const),
938
962
  detail: check.detail,
963
+ ...(check.technical === undefined ? {} : { technical: check.technical }),
939
964
  ...(check.ok
940
965
  ? {}
941
966
  : {
942
967
  remedy: {
943
- by: 'user-here' as const,
944
968
  // The check's own remedy when it has one; the generic reinstall
945
969
  // line was wrong exactly where it mattered (an unfired pointer
946
970
  // needs a prompt, not `hooks install`).
@@ -948,6 +972,7 @@ function checkTitle(name: string): string {
948
972
  summary: 'the detail above names what to change',
949
973
  command: 'notifai hooks install',
950
974
  }),
975
+ by: check.remedy?.by ?? ('user-here' as const),
951
976
  },
952
977
  }),
953
978
  })),
@@ -957,13 +982,15 @@ function checkTitle(name: string): string {
957
982
 
958
983
 
959
984
  interface HookCheck {
985
+ /** No command closes this; only a turn that has not happened yet. */
986
+ reportOnly?: boolean
960
987
  name: string
961
988
  ok: boolean
962
989
  detail: string
963
- /** Real but not in the way: worth a line, never a blocker. */
964
- informational?: boolean
990
+ technical?: unknown
965
991
  /** A remedy truer than the generic `notifai hooks install`. */
966
992
  remedy?: {
993
+ by?: 'cli' | 'user-here'
967
994
  summary: string
968
995
  command: string
969
996
  user_action?: { code: string; harness: string; action: string; message: string }
@@ -998,7 +1025,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
998
1025
  checks.push({
999
1026
  name: 'hooks (detected)',
1000
1027
  ok: false,
1001
- informational: true,
1028
+ reportOnly: true,
1002
1029
  detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
1003
1030
  remedy: {
1004
1031
  summary: 'install hooks for every detected harness',
@@ -1020,14 +1047,14 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1020
1047
  checks.push({
1021
1048
  name: 'hooks (active harness)',
1022
1049
  ok: false,
1023
- informational: true,
1050
+ reportOnly: true,
1024
1051
  detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
1025
1052
  })
1026
1053
  } else if (active !== null && !isHookInstallableHarness(active.harness)) {
1027
1054
  checks.push({
1028
1055
  name: 'hooks (active harness)',
1029
1056
  ok: true,
1030
- informational: true,
1057
+ reportOnly: true,
1031
1058
  detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
1032
1059
  })
1033
1060
  } else if (active !== null) {
@@ -1060,7 +1087,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1060
1087
  checks.push({
1061
1088
  name: 'hooks (active session)',
1062
1089
  ok: false,
1063
- informational: true,
1090
+ reportOnly: true,
1064
1091
  detail: `active ${active.label} session has not published exact lifecycle state — send one ${active.label} prompt, then check again`,
1065
1092
  remedy: {
1066
1093
  summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
@@ -1113,14 +1140,47 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1113
1140
  )
1114
1141
  const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform)
1115
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
+ }
1116
1164
  if (adapterProblems.length > 0) {
1117
1165
  checks.push({
1118
1166
  name: 'hooks (adapter)',
1119
1167
  ok: false,
1120
1168
  // A machine-global install for a harness that is not active in this
1121
- // project is useful diagnosis, but it must not block unrelated init.
1122
- informational: active === null && installations.every((installation) => installation.global),
1169
+ // project is useful diagnosis, but it is not this project's failure.
1170
+ reportOnly: active === null && installations.every((installation) => installation.global),
1123
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
+ : {}),
1124
1184
  })
1125
1185
  }
1126
1186
 
@@ -1276,9 +1336,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1276
1336
  checks.push({
1277
1337
  name: 'hooks (fired)',
1278
1338
  ok: fired,
1279
- // A wholly fresh install is informational. Once UserPromptSubmit has fired,
1280
- // a missing Stop is a broken route, not missing historical evidence.
1281
- informational: firedPointer === null,
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,
1282
1343
  detail: fired
1283
1344
  ? active === null
1284
1345
  ? 'a session in this directory has run UserPromptSubmit and Stop'
@@ -1319,7 +1380,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1319
1380
  continuationHarnesses.every(
1320
1381
  (harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
1321
1382
  ),
1322
- informational: active === null,
1383
+ reportOnly: active === null,
1323
1384
  detail:
1324
1385
  continuationHarnesses.length === 0
1325
1386
  ? active === null
@@ -1376,7 +1437,7 @@ function wakeRouteCheck(
1376
1437
  return {
1377
1438
  name: 'hooks (wake route)',
1378
1439
  ok: readiness.state === 'ready',
1379
- informational: true,
1440
+ reportOnly: true,
1380
1441
  detail:
1381
1442
  readiness.state === 'ready'
1382
1443
  ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
@@ -1391,7 +1452,7 @@ function wakeRouteCheck(
1391
1452
  return {
1392
1453
  name: 'hooks (wake route)',
1393
1454
  ok: readiness.state === 'ready',
1394
- informational: true,
1455
+ reportOnly: true,
1395
1456
  detail:
1396
1457
  readiness.state === 'ready'
1397
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`
@@ -55,6 +55,7 @@ import {
55
55
  withdrawUnpushedQuestions,
56
56
  type EscalationDeliveryRoute,
57
57
  type HookContext,
58
+ type HookOutcome,
58
59
  type HookHarness,
59
60
  type PendingQuestion,
60
61
  } from './hooks.js'
@@ -102,6 +103,7 @@ import {
102
103
  removeOpenclawNotifaiEntry,
103
104
  } from './openclaw-plugin.js'
104
105
  import { packageVersion } from './release.js'
106
+ import { spawnQuestionSettlement } from './question-settlement-process.js'
105
107
  import { enableProject, projectBinding, projectEnabled } from './project-enablement.js'
106
108
  import { rejectAccidentalEscapedNewlines } from './send.js'
107
109
  import {
@@ -156,6 +158,7 @@ export const HOOK_EVENTS = [
156
158
  'session-end',
157
159
  ] as const
158
160
  export type HookEvent = (typeof HOOK_EVENTS)[number]
161
+ const INTERNAL_HOOK_EVENTS = ['question-settlement'] as const
159
162
 
160
163
  /** Lifecycle handlers one installed harness must carry in this CLI build. */
161
164
  export function requiredHookEvents(harness: HookInstallableHarness): readonly HookEvent[] {
@@ -187,7 +190,10 @@ export async function hookRunCommand(
187
190
  readStdin: () => Promise<string>,
188
191
  harness?: HookHarness,
189
192
  ): Promise<number> {
190
- if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
193
+ if (
194
+ !(HOOK_EVENTS as readonly string[]).includes(event) &&
195
+ !(INTERNAL_HOOK_EVENTS as readonly string[]).includes(event)
196
+ ) {
191
197
  deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
192
198
  return EXIT.usage
193
199
  }
@@ -501,7 +507,9 @@ export async function hookRunCommand(
501
507
  `Bearer nfm_${credential.machineId}.${credential.secret}`,
502
508
  {
503
509
  timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
504
- ...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
510
+ ...(event === 'stop' || event === 'question-settlement'
511
+ ? { deadlineAt: processDeadlineAt, now }
512
+ : {}),
505
513
  },
506
514
  )
507
515
  const ctx: HookContext = {
@@ -533,17 +541,49 @@ export async function hookRunCommand(
533
541
  // Daily state pruning is housekeeping, not part of the Stop delivery
534
542
  // contract. Its directory scan has no useful bound, so keep it on the
535
543
  // short prompt path and never spend the answer owner's finite budget on it.
536
- if (event !== 'stop') pruneAbandonedSessions(deps.env)
537
-
538
- const outcome =
539
- event === 'user-prompt-submit'
540
- ? await handleUserPromptSubmit(ctx, envelope)
541
- : await handleStop(
542
- ctx,
543
- envelope,
544
- processDeadlineAt,
545
- stopWakeRoute(deps, harness, envelope.session_id, cwd),
546
- )
544
+ if (event !== 'stop' && event !== 'question-settlement') {
545
+ pruneAbandonedSessions(deps.env)
546
+ }
547
+
548
+ let outcome: HookOutcome
549
+ if (event === 'user-prompt-submit') {
550
+ outcome = await handleUserPromptSubmit(ctx, envelope)
551
+ if (
552
+ outcome.settlementRequired === true &&
553
+ envelope.session_id !== undefined &&
554
+ harness !== undefined &&
555
+ questionRoutingCapability(harness).stopContinuation !== 'unsupported'
556
+ ) {
557
+ try {
558
+ const launchSettlement = deps.spawnQuestionSettlement ?? spawnQuestionSettlement
559
+ launchSettlement({
560
+ envelope: { session_id: envelope.session_id, cwd },
561
+ harness,
562
+ })
563
+ outcome.log = { ...outcome.log, settlement: 'launched' }
564
+ } catch (err) {
565
+ outcome.log = {
566
+ ...outcome.log,
567
+ settlement: 'launch-failed',
568
+ settlement_error: failureData(err),
569
+ }
570
+ }
571
+ }
572
+ } else {
573
+ outcome = await handleStop(
574
+ ctx,
575
+ envelope,
576
+ processDeadlineAt,
577
+ stopWakeRoute(
578
+ deps,
579
+ harness,
580
+ envelope.session_id,
581
+ cwd,
582
+ event === 'stop',
583
+ ),
584
+ event === 'stop',
585
+ )
586
+ }
547
587
  // Answer diagnostics are already persisted once as hook.answer. Keep every
548
588
  // other note in the lifecycle record without duplicating the user's text.
549
589
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
@@ -595,6 +635,7 @@ function stopWakeRoute(
595
635
  harness: HookInstallableHarness | undefined,
596
636
  sessionId: string | undefined,
597
637
  cwd: string,
638
+ continuationActive = true,
598
639
  ): EscalationDeliveryRoute | undefined {
599
640
  if (sessionId === undefined) return undefined
600
641
  const declaredSourcePid = declaredHookSourcePid(deps)
@@ -614,6 +655,7 @@ function stopWakeRoute(
614
655
  sourcePid: deps.codexSourcePid ?? declaredSourcePid ?? process.ppid,
615
656
  env: deps.env,
616
657
  ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
658
+ continuationActive,
617
659
  })
618
660
  }
619
661
  return undefined
@@ -942,12 +984,17 @@ function recordRegisteredQuestion(
942
984
  {
943
985
  registered: true,
944
986
  question_id: questionId,
987
+ state: 'local',
988
+ submitted: false,
989
+ request_id: null,
990
+ provider_acceptance: 'not_available',
945
991
  questions: built.questions.map((entry) => ({
946
992
  id: entry.id,
947
993
  text: entry.text,
948
994
  ...(entry.choices === undefined ? {} : { choices: entry.choices }),
949
995
  ...(entry.multi === true ? { multi: true } : {}),
950
996
  })),
997
+ status: `notifai status ${questionId}`,
951
998
  close: `notifai close ${questionId}`,
952
999
  next: {
953
1000
  end_turn: true,
@@ -977,8 +1024,11 @@ function recordRegisteredQuestion(
977
1024
  }
978
1025
  deps.io.out(
979
1026
  built.questions.length > 1
980
- ? `${built.questions.length} questions registered as one form (${questionId}). Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
981
- : `Question registered (${questionId}). Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`,
1027
+ ? `${built.questions.length} questions registered locally as one form (${questionId}); they have not been submitted as a Notification Request and have no Provider Acceptance yet. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1028
+ : `Question registered locally (${questionId}); it has not been submitted as a Notification Request and has no Provider Acceptance yet. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.`,
1029
+ )
1030
+ deps.io.out(
1031
+ `Question settlement runs after registration. Inspect the original identity with \`notifai status ${questionId}\`; never register a replacement to check whether this one was sent.`,
982
1032
  )
983
1033
  deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
984
1034
  for (const [index, entry] of built.questions.entries()) {
@@ -1593,7 +1643,7 @@ async function closeLocalQuestion(
1593
1643
  if (entry === undefined) return null
1594
1644
 
1595
1645
  if (entry.request_id === undefined) {
1596
- dropPendingQuestion(sessionId, deps.env, entry)
1646
+ dropPendingQuestion(sessionId, deps.env, entry, 'withdrawn')
1597
1647
  const output = {
1598
1648
  session_id: sessionId,
1599
1649
  withdrawn: [