@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.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +8 -1
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +245 -87
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/node-floor.d.ts +18 -0
- package/dist/node-floor.js +35 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/package.json +2 -2
- package/src/cli-bin.test.ts +145 -7
- package/src/cli-bin.ts +176 -10
- package/src/cli-update-contract.ts +9 -0
- package/src/cli-update.test.ts +125 -0
- package/src/client.ts +7 -0
- package/src/codex-wake.ts +5 -1
- package/src/commands-auth.ts +44 -11
- package/src/commands-core.ts +18 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +116 -55
- package/src/commands-hooks.ts +66 -16
- package/src/commands-init.ts +265 -88
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +518 -49
- package/src/commands.ts +2 -0
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/main-run.ts +45 -0
- package/src/main.ts +9 -33
- package/src/node-floor.test.ts +27 -0
- package/src/node-floor.ts +37 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +28 -4
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +11 -0
- package/src/commands-devices.test.ts +0 -15
package/src/commands-core.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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: ${
|
|
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
|
package/src/commands-devices.ts
CHANGED
|
@@ -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
|
|
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
|
|
53
|
+
// Best-effort: the empty-state copy still names the destination.
|
|
49
54
|
}
|
|
50
55
|
deps.io.out(
|
|
51
|
-
`No devices yet.
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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"
|
package/src/commands-doctor.ts
CHANGED
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
HERMES_QUESTION_ROUTING_UNAVAILABLE,
|
|
12
12
|
isHookInstallableHarness,
|
|
13
13
|
} from './harnesses.js'
|
|
14
|
-
import {
|
|
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
|
|
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:
|
|
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:
|
|
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 ${
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
?
|
|
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:
|
|
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 (
|
|
749
|
-
|
|
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
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1122
|
-
|
|
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
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
package/src/commands-hooks.ts
CHANGED
|
@@ -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 (
|
|
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'
|
|
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')
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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: [
|