@raidiant/notifai 8.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +83 -7
- package/dist/hooks.js +288 -90
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +11 -0
- package/dist/session-activation.js +46 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/command-session.ts +38 -0
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +70 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +778 -166
- package/src/hooks.ts +368 -129
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands-send.ts
CHANGED
|
@@ -6,17 +6,18 @@ import {
|
|
|
6
6
|
type ReplyView,
|
|
7
7
|
type SubmissionReceipt,
|
|
8
8
|
} from '@raidiant/notifai-protocol'
|
|
9
|
-
import { randomBytes } from 'node:crypto'
|
|
10
9
|
import { ApiCallError, NetworkError } from './client.js'
|
|
11
10
|
import type { FlagOverrides, loadConfig } from './config.js'
|
|
12
|
-
import { MIN_REPLY_WINDOW_SECONDS,
|
|
11
|
+
import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
|
|
13
12
|
import {
|
|
14
13
|
buildDraft,
|
|
15
14
|
formatReceipt,
|
|
16
15
|
receiptExitCode,
|
|
16
|
+
rejectAccidentalEscapedNewlines,
|
|
17
17
|
validateMediaInputs,
|
|
18
18
|
type SendFlags,
|
|
19
19
|
} from './send.js'
|
|
20
|
+
import { recordObservedDeliveryProof, setupProofProject } from './commands-setup-proof.js'
|
|
20
21
|
import {
|
|
21
22
|
EXIT,
|
|
22
23
|
authedClient,
|
|
@@ -26,6 +27,13 @@ import {
|
|
|
26
27
|
type CommandDeps,
|
|
27
28
|
} from './commands-core.js'
|
|
28
29
|
import { activeHarnessSession } from './commands-harness-context.js'
|
|
30
|
+
import { resolveCommandSession } from './command-session.js'
|
|
31
|
+
import {
|
|
32
|
+
beginSendAttempt,
|
|
33
|
+
semanticMediaIds,
|
|
34
|
+
sendDraftFingerprint,
|
|
35
|
+
settleSendAttempt,
|
|
36
|
+
} from './send-attempts.js'
|
|
29
37
|
import {
|
|
30
38
|
acknowledgementCommand,
|
|
31
39
|
printAcknowledgementStatus,
|
|
@@ -47,9 +55,14 @@ export async function sendCommand(
|
|
|
47
55
|
noWait?: boolean
|
|
48
56
|
replyTimeout?: number
|
|
49
57
|
idempotencyKey?: string
|
|
58
|
+
retry?: boolean
|
|
50
59
|
baseUrl?: string
|
|
51
60
|
},
|
|
52
61
|
): Promise<number> {
|
|
62
|
+
if (flags.retry === true && flags.idempotencyKey !== undefined) {
|
|
63
|
+
deps.io.err('Pass --retry or --idempotency-key, not both.')
|
|
64
|
+
return EXIT.usage
|
|
65
|
+
}
|
|
53
66
|
const hasChoice = Array.isArray(flags.choice)
|
|
54
67
|
? flags.choice.length > 0
|
|
55
68
|
: flags.choice !== undefined
|
|
@@ -63,7 +76,7 @@ export async function sendCommand(
|
|
|
63
76
|
if (!flags.reply && flags.kind === undefined) {
|
|
64
77
|
deps.io.err(
|
|
65
78
|
'--kind is required: say what this notification is. ' +
|
|
66
|
-
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
|
|
79
|
+
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed; no User reply would resume it). ' +
|
|
67
80
|
'--reply makes it a question without --kind.',
|
|
68
81
|
)
|
|
69
82
|
return EXIT.usage
|
|
@@ -111,6 +124,11 @@ export async function sendCommand(
|
|
|
111
124
|
)
|
|
112
125
|
return EXIT.usage
|
|
113
126
|
}
|
|
127
|
+
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
|
|
128
|
+
if (escapedBody !== null) {
|
|
129
|
+
deps.io.err(escapedBody)
|
|
130
|
+
return EXIT.usage
|
|
131
|
+
}
|
|
114
132
|
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
115
133
|
if (mediaInputError !== null) {
|
|
116
134
|
deps.io.err(mediaInputError)
|
|
@@ -132,14 +150,44 @@ export async function sendCommand(
|
|
|
132
150
|
}
|
|
133
151
|
const authed = authedClient(deps, config)
|
|
134
152
|
if (!authed) return EXIT.auth
|
|
153
|
+
const credential = deps.store.load()!
|
|
154
|
+
let semanticImages: string[]
|
|
155
|
+
try {
|
|
156
|
+
semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
deps.io.err(`Could not fingerprint an image for safe retry: ${String(err)}`)
|
|
159
|
+
return EXIT.usage
|
|
160
|
+
}
|
|
161
|
+
const semanticBuild = buildDraft(
|
|
162
|
+
config,
|
|
163
|
+
{ ...flags, ...(semanticImages.length > 0 ? { image: semanticImages } : {}) },
|
|
164
|
+
source.invocation,
|
|
165
|
+
)
|
|
166
|
+
if (!semanticBuild.ok) {
|
|
167
|
+
deps.io.err(semanticBuild.error)
|
|
168
|
+
return EXIT.usage
|
|
169
|
+
}
|
|
170
|
+
const attempt = beginSendAttempt({
|
|
171
|
+
env: deps.env,
|
|
172
|
+
credential,
|
|
173
|
+
fingerprint: sendDraftFingerprint(semanticBuild.draft, credential),
|
|
174
|
+
retry: flags.retry === true,
|
|
175
|
+
...(flags.idempotencyKey === undefined ? {} : { idempotencyKey: flags.idempotencyKey }),
|
|
176
|
+
now: (deps.now ?? Date.now)(),
|
|
177
|
+
})
|
|
178
|
+
if (!attempt.ok) {
|
|
179
|
+
deps.io.err(`${attempt.code}: ${attempt.message}`)
|
|
180
|
+
return EXIT.usage
|
|
181
|
+
}
|
|
135
182
|
const mediaIds: string[] = []
|
|
136
183
|
for (const image of flags.image ?? []) {
|
|
137
184
|
if (image.startsWith('med_')) {
|
|
138
185
|
mediaIds.push(image)
|
|
139
186
|
continue
|
|
140
187
|
}
|
|
141
|
-
const uploaded = await uploadImage(deps, authed.client, image)
|
|
188
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
142
189
|
if (!uploaded.ok) {
|
|
190
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
143
191
|
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
144
192
|
return uploaded.exit
|
|
145
193
|
}
|
|
@@ -148,16 +196,19 @@ export async function sendCommand(
|
|
|
148
196
|
flags = { ...flags, image: mediaIds }
|
|
149
197
|
const build = buildDraft(config, flags, source.invocation)
|
|
150
198
|
if (!build.ok) {
|
|
199
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
151
200
|
deps.io.err(build.error)
|
|
152
201
|
return EXIT.usage
|
|
153
202
|
}
|
|
154
203
|
const capabilities = CAPABILITIES_V1.describe(build.platform)
|
|
155
204
|
if (!capabilities) {
|
|
205
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
156
206
|
deps.io.err(`No capability contract is available for ${build.platform}.`)
|
|
157
207
|
return EXIT.usage
|
|
158
208
|
}
|
|
159
209
|
const validation = validateDraft(build.draft, capabilities)
|
|
160
210
|
if (!validation.ok) {
|
|
211
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
161
212
|
for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
|
|
162
213
|
return EXIT.usage
|
|
163
214
|
}
|
|
@@ -174,7 +225,15 @@ export async function sendCommand(
|
|
|
174
225
|
)
|
|
175
226
|
}
|
|
176
227
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
177
|
-
const idempotencyKey =
|
|
228
|
+
const idempotencyKey = attempt.idempotencyKey
|
|
229
|
+
// Persist the retry identity before entering the ambiguous network boundary.
|
|
230
|
+
// If the process is killed after the server accepts but before a response,
|
|
231
|
+
// this is the only durable way to reconstruct the same Agent Event key.
|
|
232
|
+
log(deps).info('send.attempt', {
|
|
233
|
+
attempt_id: attempt.attemptId,
|
|
234
|
+
idempotency_key: idempotencyKey,
|
|
235
|
+
replay: attempt.replay,
|
|
236
|
+
})
|
|
178
237
|
let receipt: SubmissionReceipt
|
|
179
238
|
try {
|
|
180
239
|
receipt = await authed.client.submit(
|
|
@@ -182,8 +241,27 @@ export async function sendCommand(
|
|
|
182
241
|
waitSeconds,
|
|
183
242
|
)
|
|
184
243
|
} catch (err) {
|
|
185
|
-
|
|
244
|
+
const exit = reportError(deps, err)
|
|
245
|
+
if (exit === EXIT.network) {
|
|
246
|
+
deps.io.err(
|
|
247
|
+
'The server may have accepted this Notification Request before the connection failed. ' +
|
|
248
|
+
'Re-run the exact semantic send with `--retry`; the CLI will reuse its opaque attempt identity.',
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
if (err instanceof ApiCallError && err.code === 'no_active_devices') {
|
|
252
|
+
deps.io.err(
|
|
253
|
+
'After setup succeeds, repeat this exact original Notification Request with `--retry`; ' +
|
|
254
|
+
'the setup verification request does not replace this Agent Event.',
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
if (!(exit === EXIT.network || (err instanceof ApiCallError && err.code === 'no_active_devices'))) {
|
|
258
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
259
|
+
} else {
|
|
260
|
+
deps.io.err(`Opaque retry attempt: ${attempt.attemptId}. Re-run the exact semantic send with \`--retry\`; do not retry automatically.`)
|
|
261
|
+
}
|
|
262
|
+
return exit
|
|
186
263
|
}
|
|
264
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
187
265
|
const receiptExit = receiptExitCode(receipt)
|
|
188
266
|
// The single most useful line in the log: it ties the local invocation to
|
|
189
267
|
// the server-side request id, which is what every later question about this
|
|
@@ -191,7 +269,7 @@ export async function sendCommand(
|
|
|
191
269
|
log(deps).info('send.submitted', {
|
|
192
270
|
request_id: receipt.request_id,
|
|
193
271
|
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
194
|
-
|
|
272
|
+
title_chars: flags.title.length,
|
|
195
273
|
overall: receipt.overall,
|
|
196
274
|
replayed: receipt.replayed,
|
|
197
275
|
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
@@ -354,28 +432,64 @@ export async function repliesCommand(
|
|
|
354
432
|
return EXIT.usage
|
|
355
433
|
}
|
|
356
434
|
let requestIds = requestedId === undefined ? [] : [requestedId]
|
|
435
|
+
let lifecycleSessionId: string | undefined
|
|
357
436
|
if (flags.pending === true) {
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
437
|
+
const session = resolveCommandSession(deps)
|
|
438
|
+
if (session === null) {
|
|
360
439
|
deps.io.err('No active session pointer is available in this directory.')
|
|
361
440
|
return EXIT.noReply
|
|
362
441
|
}
|
|
442
|
+
const sessionId = session.sessionId
|
|
443
|
+
lifecycleSessionId = sessionId
|
|
363
444
|
// Every delivered question in the session's queue, in registration order —
|
|
364
445
|
// an agent may have several outstanding at once.
|
|
365
446
|
const state = readSessionState(sessionId, deps.env)
|
|
366
|
-
|
|
447
|
+
const pending = Array.isArray(state.pending) ? state.pending : []
|
|
448
|
+
requestIds = pending
|
|
367
449
|
.map((entry) => entry.request_id)
|
|
368
450
|
.filter((id): id is string => id !== undefined)
|
|
451
|
+
const unsent = pending.filter((entry) => entry.request_id === undefined)
|
|
369
452
|
if (requestIds.length === 0) {
|
|
370
453
|
// `--json` is a promise about stdout, and an empty result is still a
|
|
371
454
|
// result: a caller that asked for machine-readable output must not have
|
|
372
455
|
// to parse an English sentence to learn there was nothing pending.
|
|
373
456
|
if (flags.json === true) {
|
|
374
|
-
deps.io.out(
|
|
457
|
+
deps.io.out(
|
|
458
|
+
JSON.stringify(
|
|
459
|
+
{
|
|
460
|
+
session_id: sessionId,
|
|
461
|
+
pending: unsent.map((entry) => ({
|
|
462
|
+
question: entry.question,
|
|
463
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
464
|
+
})),
|
|
465
|
+
replies: [],
|
|
466
|
+
},
|
|
467
|
+
null,
|
|
468
|
+
2,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
} else if (unsent.length > 0) {
|
|
472
|
+
for (const entry of unsent) {
|
|
473
|
+
deps.io.out(
|
|
474
|
+
entry.question_id === undefined
|
|
475
|
+
? `unpushed question: ${entry.question}`
|
|
476
|
+
: `unpushed question ${entry.question_id}: ${entry.question}`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
deps.io.out('Retire one with `notifai close <question_id>` so a later Stop will not send it.')
|
|
375
480
|
} else {
|
|
376
481
|
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
377
482
|
}
|
|
378
|
-
return EXIT.noReply
|
|
483
|
+
return unsent.length > 0 ? EXIT.ok : EXIT.noReply
|
|
484
|
+
}
|
|
485
|
+
if (unsent.length > 0 && flags.json !== true) {
|
|
486
|
+
for (const entry of unsent) {
|
|
487
|
+
deps.io.out(
|
|
488
|
+
entry.question_id === undefined
|
|
489
|
+
? `unpushed question: ${entry.question}`
|
|
490
|
+
: `unpushed question ${entry.question_id}: ${entry.question}`,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
379
493
|
}
|
|
380
494
|
}
|
|
381
495
|
if (requestIds.length === 0) {
|
|
@@ -383,7 +497,11 @@ export async function repliesCommand(
|
|
|
383
497
|
return EXIT.usage
|
|
384
498
|
}
|
|
385
499
|
|
|
386
|
-
const config = loadLoggedConfig(deps, {
|
|
500
|
+
const config = loadLoggedConfig(deps, {
|
|
501
|
+
cwd: deps.cwd,
|
|
502
|
+
env: deps.env,
|
|
503
|
+
...(lifecycleSessionId === undefined ? {} : { sessionId: lifecycleSessionId }),
|
|
504
|
+
})
|
|
387
505
|
const authed = authedClient(deps, config)
|
|
388
506
|
if (!authed) return EXIT.auth
|
|
389
507
|
try {
|
|
@@ -527,8 +645,8 @@ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly R
|
|
|
527
645
|
request_id: requestId,
|
|
528
646
|
sequence: reply.seq,
|
|
529
647
|
device: reply.device_name,
|
|
530
|
-
|
|
531
|
-
|
|
648
|
+
text_chars: reply.text.length,
|
|
649
|
+
answers_count: reply.answers?.length ?? 0,
|
|
532
650
|
source: reply.source,
|
|
533
651
|
})
|
|
534
652
|
}
|
|
@@ -587,11 +705,12 @@ export async function statusCommand(
|
|
|
587
705
|
if (!authed) return EXIT.auth
|
|
588
706
|
try {
|
|
589
707
|
const snapshot = await authed.client.evidence(requestId)
|
|
708
|
+
recordObservedDeliveryProof(deps, snapshot, setupProofProject(deps, config.project.value))
|
|
590
709
|
if (flags.json) {
|
|
591
710
|
deps.io.out(JSON.stringify(snapshot, null, 2))
|
|
592
711
|
return EXIT.ok
|
|
593
712
|
}
|
|
594
|
-
deps.io.out(`request ${snapshot.request_id}
|
|
713
|
+
deps.io.out(`request ${snapshot.request_id} — ${snapshot.overall}`)
|
|
595
714
|
let anyReplyReceived = false
|
|
596
715
|
for (const d of snapshot.deliveries) {
|
|
597
716
|
deps.io.out(` ${d.device_name}:`)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { CommandDeps } from './commands-core.js'
|
|
6
|
+
import type { MachineCredential } from './credentials.js'
|
|
7
|
+
import { readSetupProof, setupProofApplies, writeSetupProof } from './commands-setup-proof.js'
|
|
8
|
+
|
|
9
|
+
const roots: string[] = []
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function deps(root: string, cwd: string, credential: MachineCredential): CommandDeps {
|
|
15
|
+
return {
|
|
16
|
+
cwd,
|
|
17
|
+
env: { XDG_STATE_HOME: path.join(root, 'state'), XDG_CONFIG_HOME: path.join(root, 'config') },
|
|
18
|
+
io: { out: () => {}, err: () => {}, confirm: async () => false, openUrl: () => {} },
|
|
19
|
+
store: {
|
|
20
|
+
load: () => credential,
|
|
21
|
+
save: () => {},
|
|
22
|
+
clear: () => {},
|
|
23
|
+
describe: () => 'test',
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('stable setup delivery proof identity', () => {
|
|
29
|
+
it('is shared by linked checkouts of one Project and separated by Project', () => {
|
|
30
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-identity-'))
|
|
31
|
+
roots.push(root)
|
|
32
|
+
const credential = {
|
|
33
|
+
machineId: 'mac_one',
|
|
34
|
+
machineName: 'One',
|
|
35
|
+
baseUrl: 'https://app.notifai.test',
|
|
36
|
+
secret: 'approval-one',
|
|
37
|
+
}
|
|
38
|
+
const first = deps(root, path.join(root, 'worktree-a'), credential)
|
|
39
|
+
const second = deps(root, path.join(root, 'worktree-b'), credential)
|
|
40
|
+
const proof = {
|
|
41
|
+
request_id: 'req_proof',
|
|
42
|
+
device_id: 'dev_one',
|
|
43
|
+
project: 'shared-project',
|
|
44
|
+
started_at: '2026-08-26T00:00:00.000Z',
|
|
45
|
+
}
|
|
46
|
+
expect(writeSetupProof(first, proof)).toBe(true)
|
|
47
|
+
expect(readSetupProof(second, 'shared-project')).toEqual(proof)
|
|
48
|
+
expect(readSetupProof(second, 'different-project')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('invalidates on service, machine approval, machine, or device change', () => {
|
|
52
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-invalidation-'))
|
|
53
|
+
roots.push(root)
|
|
54
|
+
const credential = {
|
|
55
|
+
machineId: 'mac_one',
|
|
56
|
+
machineName: 'One',
|
|
57
|
+
baseUrl: 'https://app.notifai.test',
|
|
58
|
+
secret: 'approval-one',
|
|
59
|
+
}
|
|
60
|
+
const original = deps(root, root, credential)
|
|
61
|
+
expect(writeSetupProof(original, {
|
|
62
|
+
request_id: 'req_proof',
|
|
63
|
+
device_id: 'dev_one',
|
|
64
|
+
project: 'project',
|
|
65
|
+
started_at: '2026-08-26T00:00:00.000Z',
|
|
66
|
+
})).toBe(true)
|
|
67
|
+
expect(readSetupProof(deps(root, root, { ...credential, baseUrl: 'https://other.test' }), 'project')).toBeNull()
|
|
68
|
+
expect(readSetupProof(deps(root, root, { ...credential, machineId: 'mac_two' }), 'project')).toBeNull()
|
|
69
|
+
expect(readSetupProof(deps(root, root, { ...credential, secret: 'approval-two' }), 'project')).toBeNull()
|
|
70
|
+
expect(setupProofApplies(readSetupProof(original, 'project'), 'project', ['dev_two'])).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -4,34 +4,41 @@ import {
|
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
-
realpathSync,
|
|
8
7
|
writeFileSync,
|
|
9
8
|
} from 'node:fs'
|
|
10
9
|
import path from 'node:path'
|
|
11
10
|
import { stateDir } from './config.js'
|
|
12
11
|
import type { CommandDeps } from './commands-core.js'
|
|
12
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
13
13
|
|
|
14
|
-
interface SetupProofRecord {
|
|
14
|
+
export interface SetupProofRecord {
|
|
15
15
|
request_id: string
|
|
16
16
|
device_id: string
|
|
17
17
|
project: string | null
|
|
18
18
|
started_at: string
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
export function setupProofProject(deps: CommandDeps, configured: string | null): string | null {
|
|
22
|
+
return configured ?? inferInvocationContext(deps.cwd).project
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setupProofPath(deps: CommandDeps, project: string | null): string | null {
|
|
26
|
+
const credential = deps.store.load()
|
|
27
|
+
if (credential === null) return null
|
|
28
|
+
const approval = createHash('sha256')
|
|
29
|
+
.update('notifai-setup-proof-approval-v1\0')
|
|
30
|
+
.update(credential.secret)
|
|
31
|
+
.digest('base64url')
|
|
32
|
+
const digest = createHash('sha256')
|
|
33
|
+
.update(JSON.stringify({ project, machine_id: credential.machineId, service: credential.baseUrl, approval }))
|
|
34
|
+
.digest('hex')
|
|
35
|
+
.slice(0, 32)
|
|
30
36
|
return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
34
|
-
const file = setupProofPath(deps)
|
|
39
|
+
export function readSetupProof(deps: CommandDeps, project: string | null): SetupProofRecord | null {
|
|
40
|
+
const file = setupProofPath(deps, project)
|
|
41
|
+
if (file === null) return null
|
|
35
42
|
if (!existsSync(file)) return null
|
|
36
43
|
try {
|
|
37
44
|
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
|
|
@@ -48,7 +55,11 @@ export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
|
|
51
|
-
const file = setupProofPath(deps)
|
|
58
|
+
const file = setupProofPath(deps, proof.project)
|
|
59
|
+
if (file === null) {
|
|
60
|
+
deps.io.err('Could not save setup proof without an Approved Machine credential.')
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
52
63
|
try {
|
|
53
64
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
54
65
|
writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
|
|
@@ -66,6 +77,48 @@ export function observedCompanionReceipt(
|
|
|
66
77
|
deviceId: string,
|
|
67
78
|
): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
|
|
68
79
|
const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId)
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
if (!delivery) return null
|
|
81
|
+
if (delivery.companion_receipt.state === 'observed' && delivery.companion_receipt.observed_at) {
|
|
82
|
+
return { delivery, observedAt: delivery.companion_receipt.observed_at }
|
|
83
|
+
}
|
|
84
|
+
const receipt = delivery.events.find((event) => event.stage === 'companion_received')
|
|
85
|
+
return receipt ? { delivery, observedAt: receipt.occurred_at } : null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Replace an unknown setup proof after this age; recent in-flight proofs stay. */
|
|
89
|
+
export const SETUP_PROOF_STALE_MS = 24 * 60 * 60 * 1000
|
|
90
|
+
|
|
91
|
+
export function setupProofIsStale(proof: SetupProofRecord, now: number): boolean {
|
|
92
|
+
const started = Date.parse(proof.started_at)
|
|
93
|
+
return Number.isFinite(started) && now - started > SETUP_PROOF_STALE_MS
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setupProofApplies(
|
|
97
|
+
proof: SetupProofRecord | null,
|
|
98
|
+
project: string | null,
|
|
99
|
+
deviceIds: readonly string[],
|
|
100
|
+
): proof is SetupProofRecord {
|
|
101
|
+
return proof !== null && proof.project === project && deviceIds.includes(proof.device_id)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Persist ordinary send/status Companion Receipts as the project's delivery
|
|
106
|
+
* proof so doctor does not demand a second verification request.
|
|
107
|
+
*/
|
|
108
|
+
export function recordObservedDeliveryProof(
|
|
109
|
+
deps: CommandDeps,
|
|
110
|
+
snapshot: EvidenceSnapshot,
|
|
111
|
+
project: string | null,
|
|
112
|
+
): boolean {
|
|
113
|
+
for (const delivery of snapshot.deliveries) {
|
|
114
|
+
const observed = observedCompanionReceipt(snapshot, delivery.device_id)
|
|
115
|
+
if (observed === null) continue
|
|
116
|
+
return writeSetupProof(deps, {
|
|
117
|
+
request_id: snapshot.request_id,
|
|
118
|
+
device_id: delivery.device_id,
|
|
119
|
+
project,
|
|
120
|
+
started_at: observed.observedAt,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
return false
|
|
71
124
|
}
|
package/src/commands-skill.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { SKILLS_INSTALLER_SPEC, type NativeSkill, type SkillScope } from './native-skills.js'
|
|
2
2
|
import { type ReadinessState } from './readiness.js'
|
|
3
3
|
import { skillsSource } from './release.js'
|
|
4
4
|
import type { CommandDeps } from './commands-core.js'
|
|
@@ -18,6 +18,8 @@ export const SKILLS_SOURCE: string | null = skillsSource()
|
|
|
18
18
|
*/
|
|
19
19
|
const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI'
|
|
20
20
|
|
|
21
|
+
const SKILL_SCOPES: readonly SkillScope[] = ['project', 'global']
|
|
22
|
+
|
|
21
23
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
22
24
|
if (SKILLS_SOURCE === null) return null
|
|
23
25
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -35,13 +37,23 @@ function expectedSkill(skill: NativeSkill): boolean {
|
|
|
35
37
|
)
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
function skillPin(skill: NativeSkill): string {
|
|
41
|
+
return skill.ref ?? 'unknown pin'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ScopedNotifaiSkills {
|
|
45
|
+
installed: NativeSkill[]
|
|
46
|
+
errors: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Installer-managed notifai skills in both scopes. Duplicate detection has to
|
|
51
|
+
* see the pair; a selected-scope filter would hide the copy the harness still
|
|
52
|
+
* lists.
|
|
53
|
+
*/
|
|
54
|
+
export async function listScopedNotifaiSkills(deps: CommandDeps): Promise<ScopedNotifaiSkills> {
|
|
43
55
|
const results = await Promise.all(
|
|
44
|
-
|
|
56
|
+
SKILL_SCOPES.map(async (scope) => {
|
|
45
57
|
if (deps.nativeSkills === undefined) return { scope, skills: [] as NativeSkill[] }
|
|
46
58
|
try {
|
|
47
59
|
return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) }
|
|
@@ -50,19 +62,70 @@ export async function skillReadiness(
|
|
|
50
62
|
}
|
|
51
63
|
}),
|
|
52
64
|
)
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
return {
|
|
66
|
+
installed: results.flatMap(({ skills }) => skills.filter((skill) => skill.name === 'notifai')),
|
|
67
|
+
errors: results
|
|
68
|
+
.filter((result) => result.error !== undefined)
|
|
69
|
+
.map((result) => `${result.scope}: ${result.error}`),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function duplicateSkillState(installed: NativeSkill[], selectedScope?: SkillScope): ReadinessState {
|
|
74
|
+
const project = installed.find((skill) => skill.scope === 'project')
|
|
75
|
+
const global = installed.find((skill) => skill.scope === 'global')
|
|
76
|
+
const projectPin = project === undefined ? 'not installed' : skillPin(project)
|
|
77
|
+
const globalPin = global === undefined ? 'not installed' : skillPin(global)
|
|
78
|
+
return {
|
|
79
|
+
id: 'skill',
|
|
80
|
+
title: 'Agent guidance skill',
|
|
81
|
+
status: 'gap',
|
|
82
|
+
detail:
|
|
83
|
+
`project (${projectPin}) and global (${globalPin}) are both installed, so the harness lists both. ` +
|
|
84
|
+
'Keep either project or global and uninstall the other.',
|
|
85
|
+
technical: {
|
|
86
|
+
project:
|
|
87
|
+
project === undefined
|
|
88
|
+
? null
|
|
89
|
+
: { ref: project.ref, path: project.path, current: expectedSkill(project) },
|
|
90
|
+
global:
|
|
91
|
+
global === undefined
|
|
92
|
+
? null
|
|
93
|
+
: { ref: global.ref, path: global.path, current: expectedSkill(global) },
|
|
94
|
+
resolution: 'both-listed',
|
|
95
|
+
},
|
|
96
|
+
remedy:
|
|
97
|
+
selectedScope === undefined
|
|
98
|
+
? {
|
|
99
|
+
by: 'user-here',
|
|
100
|
+
summary:
|
|
101
|
+
'keep either the project or the machine-global skill and uninstall the other; add --global to remove the machine-global copy',
|
|
102
|
+
command: `npx -y ${SKILLS_INSTALLER_SPEC} remove notifai`,
|
|
103
|
+
}
|
|
104
|
+
: {
|
|
105
|
+
by: 'cli',
|
|
106
|
+
summary: `keep the ${selectedScope} skill and uninstall the other`,
|
|
107
|
+
command: `notifai init --skills --setup-scope ${selectedScope}`,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function skillReadiness(
|
|
113
|
+
deps: CommandDeps,
|
|
114
|
+
selectedScope?: SkillScope,
|
|
115
|
+
): Promise<ReadinessState> {
|
|
116
|
+
const { installed, errors } = await listScopedNotifaiSkills(deps)
|
|
117
|
+
if (installed.length > 1) return duplicateSkillState(installed, selectedScope)
|
|
118
|
+
|
|
119
|
+
const current = installed.find(expectedSkill)
|
|
120
|
+
if (current !== undefined) {
|
|
55
121
|
return {
|
|
56
122
|
id: 'skill',
|
|
57
123
|
title: 'Agent guidance skill',
|
|
58
124
|
status: 'ready',
|
|
59
|
-
detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${
|
|
125
|
+
detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${current.scope} scope`,
|
|
60
126
|
}
|
|
61
127
|
}
|
|
62
128
|
|
|
63
|
-
const errors = results
|
|
64
|
-
.filter((result) => result.error !== undefined)
|
|
65
|
-
.map((result) => `${result.scope}: ${result.error}`)
|
|
66
129
|
const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`
|
|
67
130
|
return {
|
|
68
131
|
id: 'skill',
|
|
@@ -77,8 +140,8 @@ export async function skillReadiness(
|
|
|
77
140
|
summary: 'install the skill agents follow when deciding to notify',
|
|
78
141
|
command:
|
|
79
142
|
selectedScope === undefined
|
|
80
|
-
? 'notifai init --skills'
|
|
81
|
-
: `notifai init --skills --
|
|
143
|
+
? 'notifai init --skills --setup-scope project'
|
|
144
|
+
: `notifai init --skills --setup-scope ${selectedScope}`,
|
|
82
145
|
},
|
|
83
146
|
}
|
|
84
147
|
}
|