@raidiant/notifai 10.0.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 (127) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  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 +32 -14
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +10 -1
  19. package/dist/commands-core.js +13 -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 +158 -71
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-harness-context.js +8 -0
  27. package/dist/commands-harness-context.js.map +1 -1
  28. package/dist/commands-hooks.js +169 -15
  29. package/dist/commands-hooks.js.map +1 -1
  30. package/dist/commands-init.d.ts +16 -0
  31. package/dist/commands-init.js +254 -91
  32. package/dist/commands-init.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-update.d.ts +10 -0
  37. package/dist/commands-update.js +141 -0
  38. package/dist/commands-update.js.map +1 -0
  39. package/dist/commands.d.ts +2 -1
  40. package/dist/commands.js +2 -1
  41. package/dist/commands.js.map +1 -1
  42. package/dist/harnesses.d.ts +2 -2
  43. package/dist/harnesses.js +22 -2
  44. package/dist/harnesses.js.map +1 -1
  45. package/dist/hook-adapter.d.ts +2 -0
  46. package/dist/hook-adapter.js +12 -1
  47. package/dist/hook-adapter.js.map +1 -1
  48. package/dist/hooks.d.ts +43 -2
  49. package/dist/hooks.js +163 -21
  50. package/dist/hooks.js.map +1 -1
  51. package/dist/install-hooks.js +15 -9
  52. package/dist/install-hooks.js.map +1 -1
  53. package/dist/interactive.js +20 -23
  54. package/dist/interactive.js.map +1 -1
  55. package/dist/main-run.d.ts +1 -0
  56. package/dist/main-run.js +42 -0
  57. package/dist/main-run.js.map +1 -0
  58. package/dist/main.js +10 -31
  59. package/dist/main.js.map +1 -1
  60. package/dist/node-floor.d.ts +18 -0
  61. package/dist/node-floor.js +35 -0
  62. package/dist/node-floor.js.map +1 -0
  63. package/dist/openclaw-plugin.d.ts +49 -0
  64. package/dist/openclaw-plugin.js +350 -0
  65. package/dist/openclaw-plugin.js.map +1 -0
  66. package/dist/program.d.ts +2 -1
  67. package/dist/program.js +31 -13
  68. package/dist/program.js.map +1 -1
  69. package/dist/question-settlement-process.d.ts +15 -0
  70. package/dist/question-settlement-process.js +35 -0
  71. package/dist/question-settlement-process.js.map +1 -0
  72. package/dist/readiness.d.ts +25 -1
  73. package/dist/readiness.js +32 -2
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/session-activation.js +1 -1
  76. package/dist/session-activation.js.map +1 -1
  77. package/dist/setup-destinations.d.ts +23 -0
  78. package/dist/setup-destinations.js +44 -0
  79. package/dist/setup-destinations.js.map +1 -0
  80. package/dist/ui/help.d.ts +4 -5
  81. package/dist/ui/help.js +5 -6
  82. package/dist/ui/help.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/cli-bin.test.ts +145 -7
  85. package/src/cli-bin.ts +176 -10
  86. package/src/cli-update-contract.ts +9 -0
  87. package/src/cli-update.test.ts +125 -0
  88. package/src/client.ts +7 -0
  89. package/src/codex-wake.ts +5 -1
  90. package/src/commands-auth.ts +47 -13
  91. package/src/commands-core.ts +21 -4
  92. package/src/commands-devices.ts +23 -16
  93. package/src/commands-doctor.ts +197 -75
  94. package/src/commands-harness-context.ts +8 -0
  95. package/src/commands-hooks.ts +210 -18
  96. package/src/commands-init.ts +275 -92
  97. package/src/commands-send.ts +145 -39
  98. package/src/commands-update.ts +180 -0
  99. package/src/commands.test.ts +701 -53
  100. package/src/commands.ts +2 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +82 -0
  103. package/src/harnesses.test.ts +9 -1
  104. package/src/harnesses.ts +24 -2
  105. package/src/hook-adapter.ts +14 -0
  106. package/src/hooks.test.ts +275 -0
  107. package/src/hooks.ts +270 -18
  108. package/src/install-hooks.test.ts +208 -0
  109. package/src/install-hooks.ts +19 -9
  110. package/src/interactive.test.ts +31 -0
  111. package/src/interactive.ts +20 -26
  112. package/src/main-run.ts +45 -0
  113. package/src/main.ts +9 -33
  114. package/src/node-floor.test.ts +27 -0
  115. package/src/node-floor.ts +37 -0
  116. package/src/openclaw-plugin.ts +409 -0
  117. package/src/program.test.ts +35 -0
  118. package/src/program.ts +37 -12
  119. package/src/question-settlement-process.ts +47 -0
  120. package/src/readiness.ts +35 -2
  121. package/src/session-activation.ts +1 -1
  122. package/src/setup-destinations.test.ts +41 -0
  123. package/src/setup-destinations.ts +50 -0
  124. package/src/skill.test.ts +13 -2
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
  127. package/src/commands-devices.test.ts +0 -15
package/src/cli-bin.ts CHANGED
@@ -1,10 +1,38 @@
1
- import { accessSync, constants, existsSync, lstatSync, realpathSync } from 'node:fs'
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ existsSync,
5
+ lstatSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ } from 'node:fs'
2
9
  import path from 'node:path'
3
10
  import type { ReadinessState } from './readiness.js'
11
+ import { packageVersion } from './release.js'
12
+ import { cliUpdateRecoveryCommand } from './cli-update-contract.js'
4
13
 
5
14
  const POSIX_NAMES = ['notifai']
6
15
  const WINDOWS_NAMES = ['notifai.cmd', 'notifai.exe', 'notifai']
7
16
 
17
+ export interface CliBinReadinessOptions {
18
+ runningArtifactPath?: string
19
+ currentVersion?: string | null
20
+ }
21
+
22
+ export interface CliPathEntry {
23
+ command_path: string
24
+ executable: boolean
25
+ artifact_path: string | null
26
+ version: string | null
27
+ install_prefix: string | null
28
+ }
29
+
30
+ export interface CliInstallationInspection {
31
+ current: { artifact_path: string; version: string | null }
32
+ effective: CliPathEntry | null
33
+ entries: CliPathEntry[]
34
+ }
35
+
8
36
  export function pathNotifaiEntries(
9
37
  env: NodeJS.ProcessEnv,
10
38
  platform: NodeJS.Platform = process.platform,
@@ -36,32 +64,170 @@ export function isExecutablePath(file: string, platform: NodeJS.Platform = proce
36
64
  }
37
65
  }
38
66
 
67
+ function canonicalPath(file: string): string {
68
+ try {
69
+ return realpathSync(file)
70
+ } catch {
71
+ return path.resolve(file)
72
+ }
73
+ }
74
+
75
+ function windowsShimArtifact(file: string): string | null {
76
+ if (path.extname(file).toLowerCase() !== '.cmd') return null
77
+ try {
78
+ const source = readFileSync(file, 'utf8')
79
+ const match = /(?:%dp0%|%~dp0)?([^"\r\n]*node_modules[\\/]@raidiant[\\/]notifai[\\/]dist[\\/]main\.js)/i.exec(source)
80
+ if (match?.[0] === undefined) return null
81
+ const expanded = match[0]
82
+ .replace(/^%~?dp0%/i, `${path.dirname(file)}${path.sep}`)
83
+ .replaceAll('\\', path.sep)
84
+ return canonicalPath(expanded)
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ function artifactForCommand(file: string, platform: NodeJS.Platform): string | null {
91
+ const shim = platform === 'win32' ? windowsShimArtifact(file) : null
92
+ if (shim !== null) return shim
93
+ try {
94
+ return canonicalPath(file)
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ function artifactVersion(artifact: string | null): string | null {
101
+ if (artifact === null) return null
102
+ try {
103
+ const parsed: unknown = JSON.parse(
104
+ readFileSync(path.join(path.dirname(artifact), '..', 'package.json'), 'utf8'),
105
+ )
106
+ if (typeof parsed !== 'object' || parsed === null) return null
107
+ const version = (parsed as { version?: unknown }).version
108
+ return typeof version === 'string' && version !== '' ? version : null
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ function installPrefix(artifact: string | null): string | null {
115
+ if (artifact !== null) {
116
+ const marker = /[\\/]lib[\\/]node_modules[\\/]@raidiant[\\/]notifai[\\/]/i.exec(artifact)
117
+ ?? /[\\/]node_modules[\\/]@raidiant[\\/]notifai[\\/]/i.exec(artifact)
118
+ if (marker?.index !== undefined) return artifact.slice(0, marker.index)
119
+ }
120
+ return null
121
+ }
122
+
123
+ export function inspectCliInstallations(
124
+ env: NodeJS.ProcessEnv,
125
+ platform: NodeJS.Platform = process.platform,
126
+ options: CliBinReadinessOptions = {},
127
+ ): CliInstallationInspection {
128
+ const runningArtifact = canonicalPath(options.runningArtifactPath ?? process.argv[1] ?? 'notifai')
129
+ const entries = pathNotifaiEntries(env, platform).map((command): CliPathEntry => {
130
+ const artifact = artifactForCommand(command, platform)
131
+ return {
132
+ command_path: command,
133
+ executable: isExecutablePath(command, platform),
134
+ artifact_path: artifact,
135
+ version: artifactVersion(artifact),
136
+ install_prefix: installPrefix(artifact),
137
+ }
138
+ })
139
+ return {
140
+ current: {
141
+ artifact_path: runningArtifact,
142
+ version: options.currentVersion === undefined ? packageVersion() : options.currentVersion,
143
+ },
144
+ effective: entries.find((entry) => entry.executable) ?? null,
145
+ entries,
146
+ }
147
+ }
148
+
39
149
  export function cliBinReadiness(
40
150
  env: NodeJS.ProcessEnv,
41
151
  platform: NodeJS.Platform = process.platform,
152
+ options: CliBinReadinessOptions = {},
42
153
  ): ReadinessState {
43
- const entries = pathNotifaiEntries(env, platform)
44
- const broken = entries.find((file) => !isExecutablePath(file, platform))
45
- if (broken !== undefined) {
154
+ const inspection = inspectCliInstallations(env, platform, options)
155
+ const { current, effective, entries } = inspection
156
+ const updateCommand = cliUpdateRecoveryCommand()
157
+ if (effective === null && entries.length > 0) {
46
158
  return {
47
159
  id: 'cli-bin',
48
160
  title: 'notifai command',
49
161
  status: 'gap',
50
- detail: `${broken} is on PATH but is not executable`,
162
+ detail: 'the notifai command is on PATH but cannot run',
163
+ technical: inspection,
51
164
  remedy: {
52
165
  by: 'user-here',
53
166
  summary: 'repair the global notifai command so it can run',
54
- command: 'npm install -g @raidiant/notifai',
167
+ command: updateCommand,
168
+ },
169
+ }
170
+ }
171
+ // A global install that landed outside PATH used to report `ready` with
172
+ // "this process can run" — which is true of the running process and false of
173
+ // every instruction it goes on to print. Hooks embed absolute paths, so
174
+ // nothing visibly breaks until the reader types `notifai` themselves.
175
+ //
176
+ // Not a blocker: this process is already running, so the whole setup —
177
+ // pairing, the app, the delivery proof — still completes. It is the later
178
+ // `notifai …` lines that will not be found, and saying so is the fix.
179
+ if (effective === null) {
180
+ return {
181
+ id: 'cli-bin',
182
+ title: 'notifai command',
183
+ status: 'optional-gap',
184
+ detail:
185
+ 'no `notifai` on PATH — this process runs, but a typed `notifai …` command will not be found',
186
+ remedy: {
187
+ by: 'user-here',
188
+ summary: 'install notifai globally so the command is on your PATH',
189
+ command: updateCommand,
190
+ },
191
+ }
192
+ }
193
+
194
+ const effectiveIsCurrent =
195
+ effective.artifact_path === current.artifact_path ||
196
+ (effective.version !== null && current.version !== null && effective.version === current.version)
197
+ if (!effectiveIsCurrent) {
198
+ return {
199
+ id: 'cli-bin',
200
+ title: 'notifai command',
201
+ status: 'gap',
202
+ detail: 'the notifai command resolves to a different installation than this current CLI',
203
+ technical: inspection,
204
+ remedy: {
205
+ by: 'user-here',
206
+ summary: 'update the notifai command that wins PATH',
207
+ command: updateCommand,
55
208
  },
56
209
  }
57
210
  }
211
+
212
+ const duplicateNeedsCleanup = entries.some(
213
+ (entry) =>
214
+ entry !== effective &&
215
+ (!entry.executable || entry.artifact_path !== effective.artifact_path),
216
+ )
217
+ if (duplicateNeedsCleanup) {
218
+ return {
219
+ id: 'cli-bin',
220
+ title: 'notifai command',
221
+ status: 'optional-gap',
222
+ detail: 'the notifai command is ready; another installation remains for cleanup',
223
+ technical: inspection,
224
+ }
225
+ }
58
226
  return {
59
227
  id: 'cli-bin',
60
228
  title: 'notifai command',
61
229
  status: 'ready',
62
- detail:
63
- entries[0] === undefined
64
- ? 'this process can run; no notifai binary was on PATH'
65
- : `${entries[0]} is executable`,
230
+ detail: 'the notifai command is ready',
231
+ technical: inspection,
66
232
  }
67
233
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolve the current published CLI independently of every installed
3
+ * `notifai` command. The fetched build owns the locally derived prefix repair,
4
+ * so this action neither invokes the stale PATH winner nor exposes local
5
+ * installation paths.
6
+ */
7
+ export function cliUpdateRecoveryCommand(): string {
8
+ return 'npx --yes @raidiant/notifai@latest update'
9
+ }
@@ -0,0 +1,125 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { describe, expect, it } from 'vitest'
14
+ import type { CommandDeps, CommandIo } from './commands-core.js'
15
+ import { cliUpdateCommand } from './commands-update.js'
16
+ import { installHookAdapter, inspectHookAdapter } from './hook-adapter.js'
17
+ import { packageVersion } from './release.js'
18
+
19
+ class CapturedIo implements CommandIo {
20
+ outLines: string[] = []
21
+ errLines: string[] = []
22
+ out(line: string) { this.outLines.push(line) }
23
+ err(line: string) { this.errLines.push(line) }
24
+ async confirm() { return false }
25
+ openUrl() {}
26
+ }
27
+
28
+ function npmInstall(root: string, name: string, version: string) {
29
+ const prefix = path.join(root, name)
30
+ const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai')
31
+ const artifact = path.join(packageRoot, 'dist', 'main.js')
32
+ const command = path.join(prefix, 'bin', 'notifai')
33
+ mkdirSync(path.dirname(artifact), { recursive: true })
34
+ mkdirSync(path.dirname(command), { recursive: true })
35
+ writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version }))
36
+ writeFileSync(artifact, `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(version)} + "\\n")\n`)
37
+ chmodSync(artifact, 0o755)
38
+ symlinkSync(path.relative(path.dirname(command), artifact), command)
39
+ return { prefix, packageRoot, artifact, command }
40
+ }
41
+
42
+ describe('CLI update recovery', () => {
43
+ it('updates the PATH winner prefix and retargets the shared hook adapter in one action', () => {
44
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-loop-'))
45
+ const currentVersion = packageVersion()
46
+ if (currentVersion === null) throw new Error('test build has no package version')
47
+ const stale = npmInstall(root, 'stale-prefix', '3.0.1')
48
+ const current = npmInstall(root, 'current-prefix', currentVersion)
49
+ const managerPrefix = path.join(root, 'manager-prefix')
50
+ const managerBin = path.join(managerPrefix, 'bin')
51
+ const npm = path.join(managerBin, 'npm')
52
+ const calls = path.join(root, 'npm-calls.jsonl')
53
+ mkdirSync(managerBin, { recursive: true })
54
+ writeFileSync(
55
+ npm,
56
+ `#!${process.execPath}
57
+ const fs = require('node:fs');
58
+ const path = require('node:path');
59
+ const args = process.argv.slice(2);
60
+ fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + '\\n');
61
+ if (args[0] === 'prefix') {
62
+ process.stdout.write(${JSON.stringify(managerPrefix)} + '\\n');
63
+ process.exit(0);
64
+ }
65
+ const prefix = args[args.indexOf('--prefix') + 1];
66
+ const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai');
67
+ const artifact = path.join(packageRoot, 'dist', 'main.js');
68
+ fs.mkdirSync(path.dirname(artifact), { recursive: true });
69
+ fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: ${JSON.stringify(currentVersion)} }));
70
+ fs.writeFileSync(artifact, '#!${process.execPath}\\nprocess.stdout.write(${JSON.stringify(`${currentVersion}\\n`)})\\n', { mode: 0o755 });
71
+ `,
72
+ )
73
+ chmodSync(npm, 0o755)
74
+
75
+ const home = path.join(root, 'home')
76
+ installHookAdapter({ execPath: process.execPath, scriptPath: stale.artifact }, home)
77
+ const io = new CapturedIo()
78
+ const deps: CommandDeps = {
79
+ io,
80
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test' },
81
+ env: {
82
+ PATH: [managerBin, path.dirname(stale.command), path.dirname(current.command)].join(':'),
83
+ },
84
+ cwd: root,
85
+ hookAdapterHome: home,
86
+ hookInstallTarget: { execPath: process.execPath, scriptPath: current.artifact },
87
+ hookPlatform: 'darwin',
88
+ }
89
+
90
+ expect(cliUpdateCommand(deps, {})).toBe(0)
91
+ expect(JSON.parse(readFileSync(path.join(stale.packageRoot, 'package.json'), 'utf8'))).toMatchObject({
92
+ version: currentVersion,
93
+ })
94
+ expect(
95
+ readFileSync(calls, 'utf8').trim().split('\n').map((line) => JSON.parse(line)),
96
+ ).toEqual([
97
+ ['prefix', '--global'],
98
+ ['install', '--global', '--prefix', realpathSync(stale.prefix), '@raidiant/notifai'],
99
+ ])
100
+ expect(spawnSync(stale.command, ['--version'], { encoding: 'utf8' }).stdout.trim()).toBe(currentVersion)
101
+ expect(inspectHookAdapter(home).target).toMatchObject({ scriptPath: realpathSync(stale.artifact) })
102
+ expect(io.outLines).toEqual(['Notifai is updated. Re-run `notifai init` to continue setup.'])
103
+ expect(io.outLines.join('\n')).not.toContain(root)
104
+ expect(io.errLines).toEqual([])
105
+
106
+ io.outLines = []
107
+ expect(cliUpdateCommand(deps, { json: true })).toBe(0)
108
+ const result = JSON.parse(io.outLines[0] ?? '{}') as Record<string, unknown>
109
+ expect(result).toMatchObject({
110
+ ok: true,
111
+ package_manager_prefix: managerPrefix,
112
+ update_prefix: realpathSync(stale.prefix),
113
+ after: {
114
+ effective: {
115
+ command_path: stale.command,
116
+ artifact_path: realpathSync(stale.artifact),
117
+ version: currentVersion,
118
+ },
119
+ },
120
+ hook_adapter: {
121
+ target: { scriptPath: realpathSync(stale.artifact) },
122
+ },
123
+ })
124
+ })
125
+ })
package/src/client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ApiErrorBody,
3
+ AccountAccessRequestResponse,
3
4
  AccountAccessResponse,
4
5
  BeginPairingResponse,
5
6
  CapabilityDocument,
@@ -78,6 +79,11 @@ export interface ApiClient {
78
79
  }): Promise<BeginPairingResponse>
79
80
  pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
80
81
  accessStatus(): Promise<AccountAccessResponse>
82
+ /**
83
+ * Read-only. There is no client-side write: filing this errand is the
84
+ * User's decision, and the server refuses it from a machine credential.
85
+ */
86
+ accessRequest(): Promise<AccountAccessRequestResponse>
81
87
  listDevices(): Promise<ListDevicesResponse>
82
88
  listSounds(): Promise<ListSoundsResponse>
83
89
  capabilities(
@@ -254,6 +260,7 @@ export function createClient(
254
260
  poll_verifier: pollVerifier,
255
261
  }),
256
262
  accessStatus: () => call('GET', '/api/v1/account/access'),
263
+ accessRequest: () => call('GET', '/api/v1/account/alpha-access-request'),
257
264
  listDevices: () => call('GET', '/api/v1/devices'),
258
265
  listSounds: () => call('GET', '/api/v1/sounds'),
259
266
  capabilities: (platform = 'ios', appVersion, appBuild) => {
package/src/codex-wake.ts CHANGED
@@ -137,6 +137,8 @@ export function codexWakeRoute(options: {
137
137
  sourcePid: number
138
138
  env?: NodeJS.ProcessEnv
139
139
  adapters?: CodexWakeAdapters
140
+ /** False for a detached recovery owner whose stdout no harness will read. */
141
+ continuationActive?: boolean
140
142
  }): EscalationDeliveryRoute {
141
143
  const env = options.env ?? process.env
142
144
  const adapters = options.adapters ?? systemCodexWakeAdapters(env)
@@ -144,7 +146,9 @@ export function codexWakeRoute(options: {
144
146
  return {
145
147
  kind: 'hook-continuation',
146
148
  async deliver(event: ContinuationEvent): Promise<DeliveryOutcome> {
147
- if (adapters.sourceAlive(options.sourcePid)) return continuation.deliver(event)
149
+ if (options.continuationActive !== false && adapters.sourceAlive(options.sourcePid)) {
150
+ return continuation.deliver(event)
151
+ }
148
152
 
149
153
  // Probe immediately before the spawn, twice, and never trust the earlier
150
154
  // answer: ownership is a kernel fact that can change between two syscalls,
@@ -7,13 +7,15 @@ import { type FlagOverrides } from './config.js'
7
7
  import { checkApproveUrl } from './url-policy.js'
8
8
  import {
9
9
  EXIT,
10
+ SETUP_COMMAND,
10
11
  authedClient,
11
12
  loadLoggedConfig,
12
13
  makeClient,
13
14
  reportError,
14
15
  type CommandDeps,
15
16
  } from './commands-core.js'
16
- import { supportPageUrl } from './commands-devices.js'
17
+ import { setupAccessUrl } from './setup-destinations.js'
18
+ import type { ReadinessState } from './readiness.js'
17
19
 
18
20
  // ---------------------------------------------------------------------------
19
21
  // login / logout / auth status
@@ -30,9 +32,24 @@ export function pairingApprovalUrl(approveUrl: string, confirmationSecret: strin
30
32
  return url.toString()
31
33
  }
32
34
 
35
+ /**
36
+ * Why a sign-in stopped, in the shape the close line renders.
37
+ *
38
+ * Without it, `init` fell back to the state it had before the attempt — "this
39
+ * machine is not paired … run `notifai init`" — so the last thing a User read
40
+ * contradicted the correct line three lines above it and pointed back at the
41
+ * command that had just failed, for a reason it never mentioned.
42
+ *
43
+ * Passing one also transfers the errand: whoever takes the blocker prints the
44
+ * close, so this command stops after naming what stopped. A sign-in run on its
45
+ * own has no such caller and states the errand itself.
46
+ */
47
+ export type LoginBlockedSink = (blocker: ReadinessState) => void
48
+
33
49
  export async function loginCommand(
34
50
  deps: CommandDeps,
35
51
  flags: { name?: string; baseUrl?: string; open?: boolean },
52
+ onBlocked?: LoginBlockedSink,
36
53
  ): Promise<number> {
37
54
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
38
55
  const baseUrl = config.base_url.value
@@ -121,21 +138,39 @@ export async function loginCommand(
121
138
  deps.io.err('Pairing was denied from the dashboard.')
122
139
  return EXIT.auth
123
140
  }
141
+ // Proof-gated: the server never returns this from lookup. Stop now rather
142
+ // than waiting out a TTL no approval can arrive within.
124
143
  if (poll.status === 'no_active_plan') {
125
- const next =
126
- poll.next_action ??
127
- `Open ${supportPageUrl(baseUrl)} to request Alpha access, then retry.`
128
- spinner?.error('Account has no Alpha access')
129
- deps.io.err('This account has no active plan or temporary Alpha access.')
130
- deps.io.err(`next: ${next}`)
131
- deps.io.err('After access is granted, run `notifai login` again.')
144
+ // The server owns which access errand is current — requesting it today,
145
+ // choosing a plan after cutover — so its line wins whenever it sends one.
146
+ const next = poll.next_action ?? `Open ${setupAccessUrl(baseUrl)} to set up access, then retry.`
147
+ // Ends the wait, like its three siblings above and below; the wall
148
+ // itself belongs to the line under it, which said the same sentence a
149
+ // second time when this one tried to say it first.
150
+ spinner?.error('Pairing stopped')
151
+ // One wall, said once. When a caller takes the blocker it closes the
152
+ // visit on this exact errand, so repeating the destination here — and
153
+ // naming `init` as the way past a wall no command of theirs can open —
154
+ // would leave the User three phrasings of one step to choose between.
155
+ if (onBlocked === undefined) {
156
+ deps.io.err('This account has no active plan or temporary Alpha access.')
157
+ deps.io.err(`next: ${next}`)
158
+ deps.io.err(`After access is granted, run \`${SETUP_COMMAND}\` again.`)
159
+ }
160
+ onBlocked?.({
161
+ id: 'auth',
162
+ title: 'Access',
163
+ status: 'gap',
164
+ detail: 'this account does not have access to Notifai yet',
165
+ remedy: { by: 'user-elsewhere', summary: next },
166
+ })
132
167
  return EXIT.auth
133
168
  }
134
169
  if (poll.status === 'expired') break
135
170
  spinner?.message(approvalWaitMessage())
136
171
  }
137
172
  spinner?.error('Pairing expired')
138
- deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
173
+ deps.io.err(`Pairing expired before it was approved. Run \`${SETUP_COMMAND}\` again.`)
139
174
  return EXIT.auth
140
175
  }
141
176
 
@@ -166,7 +201,7 @@ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }):
166
201
  return credential ? EXIT.ok : EXIT.auth
167
202
  }
168
203
  if (!credential) {
169
- deps.io.err('Not signed in. Run `notifai login`.')
204
+ deps.io.err(`Not signed in. Run \`${SETUP_COMMAND}\`; it will coordinate machine login and device setup.`)
170
205
  return EXIT.auth
171
206
  }
172
207
  deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`)
@@ -190,9 +225,8 @@ export async function accessStatusCommand(
190
225
  return access.status === 'active' ? EXIT.ok : EXIT.failed
191
226
  }
192
227
  if (access.status === 'no_active_plan') {
193
- const supportUrl = supportPageUrl(authed.baseUrl)
194
- deps.io.out('No active plan or temporary Alpha access for this account.')
195
- deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
228
+ deps.io.out('This account does not have access to Notifai yet.')
229
+ deps.io.out(`next: Open ${setupAccessUrl(authed.baseUrl)} to set up access, then retry.`)
196
230
  return EXIT.failed
197
231
  }
198
232
  const expiry = access.expires_at ? ` until ${access.expires_at}` : ''
@@ -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,16 @@ 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
+ }
198
+
199
+ /** The one first-run command an unsigned machine is told to run. */
200
+ export const SETUP_COMMAND = 'notifai init'
188
201
 
189
202
  function affectedDeviceNames(details: unknown): string[] {
190
203
  if (typeof details !== 'object' || details === null || !('device_names' in details)) return []
@@ -199,10 +212,14 @@ function naturalList(values: readonly string[]): string {
199
212
  return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`
200
213
  }
201
214
 
202
- 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 {
203
220
  switch (action) {
204
221
  case 'update_cli':
205
- return `next: ${UPDATE_CLI_COMMAND}`
222
+ return `next: ${updateCliCommand(deps)}`
206
223
  case 'update_companion': {
207
224
  const names = affectedDeviceNames(details)
208
225
  return names.length > 0
@@ -268,7 +285,7 @@ export function reportError(
268
285
  const recovery =
269
286
  err.code === 'no_active_devices'
270
287
  ? 'next: Run `notifai init` to connect an active Companion App; it will complete machine setup and identify any human-only device step.'
271
- : localRecovery(err.recoveryAction, err.details)
288
+ : localRecovery(deps, err.recoveryAction, err.details)
272
289
  if (recovery !== null) deps.io.err(recovery)
273
290
  else if (err.recoveryAction === null && err.nextAction) deps.io.err(`next: ${err.nextAction}`)
274
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"