@raidiant/notifai 7.0.2 → 9.0.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 +75 -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/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-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- 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 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- 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 +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- 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 +14 -9
- 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 +5 -487
- 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 +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- 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 +6 -4
- 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/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type ListRepliesResponse } from '@raidiant/notifai-protocol'
|
|
1
|
+
import { NOTIFICATION_IMAGE_MAX_BYTES, type ListRepliesResponse } from '@raidiant/notifai-protocol'
|
|
2
2
|
import { sha256Hex } from '@raidiant/notifai-protocol/node'
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { isRetryableReplyPollError, type ApiClient } from './client.js'
|
|
6
|
+
import { fetchMediaUrl } from './url-policy.js'
|
|
6
7
|
import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
|
|
7
8
|
import { readOrcaSessionTitle } from './orca-session-title.js'
|
|
8
9
|
import { type DraftInvocation, type SendFlags } from './send.js'
|
|
@@ -15,10 +16,12 @@ function managedSessionTitle(
|
|
|
15
16
|
active: ActiveHarnessSession | null,
|
|
16
17
|
): string | undefined {
|
|
17
18
|
if (active?.sessionLabel !== undefined) return active.sessionLabel
|
|
19
|
+
// The Orca worktree title names the workspace, not one harness: every
|
|
20
|
+
// harness session Orca starts inside that worktree inherits the same
|
|
21
|
+
// environment selector, so any active session with an identity to label
|
|
22
|
+
// may use it. An explicit label still wins without paying for the lookup.
|
|
18
23
|
if (
|
|
19
|
-
active?.
|
|
20
|
-
active.sessionId === undefined ||
|
|
21
|
-
active.sessionId !== deps.env['CLAUDE_CODE_SESSION_ID'] ||
|
|
24
|
+
active?.sessionId === undefined ||
|
|
22
25
|
flags.sessionLabel !== undefined ||
|
|
23
26
|
deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
|
|
24
27
|
) {
|
|
@@ -207,17 +210,78 @@ type UploadResult =
|
|
|
207
210
|
/** `error: null` means `reportError` already said it; do not print it twice. */
|
|
208
211
|
| { ok: false; error: string | null; exit: number }
|
|
209
212
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Read a remote image without buffering more than the server will accept.
|
|
215
|
+
*
|
|
216
|
+
* Content-Length is an early refusal, not trust: a lying header still hits
|
|
217
|
+
* the byte cap while the body is read. Supported schemes stay http and https.
|
|
218
|
+
*/
|
|
219
|
+
export async function readCappedBytes(
|
|
220
|
+
response: Response,
|
|
221
|
+
maxBytes: number,
|
|
222
|
+
): Promise<Uint8Array | 'too-large'> {
|
|
223
|
+
const contentLength = Number(response.headers.get('content-length'))
|
|
224
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) return 'too-large'
|
|
225
|
+
const reader = response.body?.getReader()
|
|
226
|
+
if (reader === undefined) {
|
|
227
|
+
const buf = new Uint8Array(await response.arrayBuffer())
|
|
228
|
+
return buf.byteLength > maxBytes ? 'too-large' : buf
|
|
229
|
+
}
|
|
230
|
+
const chunks: Uint8Array[] = []
|
|
231
|
+
let received = 0
|
|
232
|
+
while (true) {
|
|
233
|
+
const { done, value } = await reader.read()
|
|
234
|
+
if (done) break
|
|
235
|
+
received += value.byteLength
|
|
236
|
+
if (received > maxBytes) {
|
|
237
|
+
await reader.cancel()
|
|
238
|
+
return 'too-large'
|
|
239
|
+
}
|
|
240
|
+
chunks.push(value)
|
|
241
|
+
}
|
|
242
|
+
const out = new Uint8Array(received)
|
|
243
|
+
let offset = 0
|
|
244
|
+
for (const chunk of chunks) {
|
|
245
|
+
out.set(chunk, offset)
|
|
246
|
+
offset += chunk.byteLength
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `--image` accepts a media id, a local file path, or an http(s) URL.
|
|
253
|
+
*
|
|
254
|
+
* A remote URL is fetched under the media URL policy (`url-policy.ts`):
|
|
255
|
+
* public HTTPS by default, every redirect hop re-validated, and non-public
|
|
256
|
+
* destinations only through the User's `media_origins` — never a
|
|
257
|
+
* repository's. `--image` values routinely arrive from an agent that read
|
|
258
|
+
* them in a working tree, so the URL is hostile input until proven otherwise.
|
|
259
|
+
*/
|
|
260
|
+
export async function uploadImage(
|
|
261
|
+
deps: CommandDeps,
|
|
262
|
+
client: ApiClient,
|
|
263
|
+
source: string,
|
|
264
|
+
allowOrigins: readonly string[] = [],
|
|
265
|
+
): Promise<UploadResult> {
|
|
212
266
|
let bytes: Uint8Array
|
|
213
267
|
let mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | undefined
|
|
214
268
|
if (/^https?:\/\//.test(source)) {
|
|
215
269
|
try {
|
|
216
|
-
const
|
|
270
|
+
const fetched = await fetchMediaUrl(source, { allowOrigins }, deps.fetchImpl)
|
|
271
|
+
if (!fetched.ok) return { ok: false, error: fetched.reason, exit: EXIT.usage }
|
|
272
|
+
const response = fetched.response
|
|
217
273
|
if (!response.ok) return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage }
|
|
218
274
|
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
|
|
219
275
|
mediaType = (['image/jpeg', 'image/png', 'image/gif'] as const).find((t) => t === contentType)
|
|
220
|
-
|
|
276
|
+
const downloaded = await readCappedBytes(response, NOTIFICATION_IMAGE_MAX_BYTES)
|
|
277
|
+
if (downloaded === 'too-large') {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: `Remote image exceeds the ${NOTIFICATION_IMAGE_MAX_BYTES} byte media limit.`,
|
|
281
|
+
exit: EXIT.usage,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
bytes = downloaded
|
|
221
285
|
} catch (err) {
|
|
222
286
|
return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network }
|
|
223
287
|
}
|
package/src/commands-send.ts
CHANGED
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
buildDraft,
|
|
15
15
|
formatReceipt,
|
|
16
16
|
receiptExitCode,
|
|
17
|
+
rejectAccidentalEscapedNewlines,
|
|
17
18
|
validateMediaInputs,
|
|
18
19
|
type SendFlags,
|
|
19
20
|
} from './send.js'
|
|
21
|
+
import { recordObservedDeliveryProof } from './commands-setup-proof.js'
|
|
20
22
|
import {
|
|
21
23
|
EXIT,
|
|
22
24
|
authedClient,
|
|
@@ -46,14 +48,13 @@ export async function sendCommand(
|
|
|
46
48
|
wait?: number
|
|
47
49
|
noWait?: boolean
|
|
48
50
|
replyTimeout?: number
|
|
49
|
-
noBlock?: boolean
|
|
50
51
|
idempotencyKey?: string
|
|
51
52
|
baseUrl?: string
|
|
52
53
|
},
|
|
53
54
|
): Promise<number> {
|
|
54
|
-
const
|
|
55
|
-
? flags.
|
|
56
|
-
: flags.
|
|
55
|
+
const hasChoice = Array.isArray(flags.choice)
|
|
56
|
+
? flags.choice.length > 0
|
|
57
|
+
: flags.choice !== undefined
|
|
57
58
|
if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
|
|
58
59
|
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
|
|
59
60
|
return EXIT.usage
|
|
@@ -64,7 +65,7 @@ export async function sendCommand(
|
|
|
64
65
|
if (!flags.reply && flags.kind === undefined) {
|
|
65
66
|
deps.io.err(
|
|
66
67
|
'--kind is required: say what this notification is. ' +
|
|
67
|
-
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
|
|
68
|
+
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed; no User reply would resume it). ' +
|
|
68
69
|
'--reply makes it a question without --kind.',
|
|
69
70
|
)
|
|
70
71
|
return EXIT.usage
|
|
@@ -75,9 +76,9 @@ export async function sendCommand(
|
|
|
75
76
|
}
|
|
76
77
|
if (
|
|
77
78
|
!flags.reply &&
|
|
78
|
-
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined ||
|
|
79
|
+
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined || hasChoice)
|
|
79
80
|
) {
|
|
80
|
-
deps.io.err('Use --reply with --reply-timeout, --reply-window,
|
|
81
|
+
deps.io.err('Use --reply with --reply-timeout, --reply-window, or --choice.')
|
|
81
82
|
return EXIT.usage
|
|
82
83
|
}
|
|
83
84
|
const replyTimeout = flags.replyTimeout ?? 900
|
|
@@ -91,13 +92,10 @@ export async function sendCommand(
|
|
|
91
92
|
// request id. So the user gets a real button, taps it, and nothing happens —
|
|
92
93
|
// worse than a banner that never asked, because it spends their attention
|
|
93
94
|
// and their trust in the channel.
|
|
94
|
-
|
|
95
|
-
// Both spellings of "do not wait" are rejected, because the defect is the
|
|
96
|
-
// zero wait and not the flag that produced it.
|
|
97
|
-
if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
|
|
95
|
+
if (flags.reply && replyTimeout === 0) {
|
|
98
96
|
deps.io.err(
|
|
99
97
|
'A question needs someone to hear the answer, so --reply cannot be combined ' +
|
|
100
|
-
'with --
|
|
98
|
+
'with --reply-timeout 0.\n' +
|
|
101
99
|
'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
|
|
102
100
|
'To announce finished work, drop --reply and its choices.',
|
|
103
101
|
)
|
|
@@ -115,6 +113,11 @@ export async function sendCommand(
|
|
|
115
113
|
)
|
|
116
114
|
return EXIT.usage
|
|
117
115
|
}
|
|
116
|
+
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
|
|
117
|
+
if (escapedBody !== null) {
|
|
118
|
+
deps.io.err(escapedBody)
|
|
119
|
+
return EXIT.usage
|
|
120
|
+
}
|
|
118
121
|
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
119
122
|
if (mediaInputError !== null) {
|
|
120
123
|
deps.io.err(mediaInputError)
|
|
@@ -142,7 +145,7 @@ export async function sendCommand(
|
|
|
142
145
|
mediaIds.push(image)
|
|
143
146
|
continue
|
|
144
147
|
}
|
|
145
|
-
const uploaded = await uploadImage(deps, authed.client, image)
|
|
148
|
+
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
146
149
|
if (!uploaded.ok) {
|
|
147
150
|
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
148
151
|
return uploaded.exit
|
|
@@ -165,17 +168,29 @@ export async function sendCommand(
|
|
|
165
168
|
for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
|
|
166
169
|
return EXIT.usage
|
|
167
170
|
}
|
|
171
|
+
for (const warning of validation.warnings) {
|
|
172
|
+
deps.io.err(`Heads up (${warning.path}): ${warning.message}`)
|
|
173
|
+
}
|
|
168
174
|
emitSendWarnings(deps, flags, config)
|
|
169
175
|
if (
|
|
170
176
|
!flags.reply &&
|
|
171
177
|
(flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
|
|
172
178
|
) {
|
|
173
179
|
deps.io.err(
|
|
174
|
-
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --
|
|
180
|
+
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --choice) so it can be answered from the notification.',
|
|
175
181
|
)
|
|
176
182
|
}
|
|
177
183
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
178
184
|
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
185
|
+
// Persist the retry identity before entering the ambiguous network boundary.
|
|
186
|
+
// If the process is killed after the server accepts but before a response,
|
|
187
|
+
// this is the only durable way to reconstruct the same Agent Event key.
|
|
188
|
+
log(deps).info('send.attempt', {
|
|
189
|
+
idempotency_key: idempotencyKey,
|
|
190
|
+
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
191
|
+
title: flags.title,
|
|
192
|
+
session_id: source.invocation.source?.session_id ?? null,
|
|
193
|
+
})
|
|
179
194
|
let receipt: SubmissionReceipt
|
|
180
195
|
try {
|
|
181
196
|
receipt = await authed.client.submit(
|
|
@@ -183,7 +198,20 @@ export async function sendCommand(
|
|
|
183
198
|
waitSeconds,
|
|
184
199
|
)
|
|
185
200
|
} catch (err) {
|
|
186
|
-
|
|
201
|
+
const exit = reportError(deps, err)
|
|
202
|
+
if (exit === EXIT.network) {
|
|
203
|
+
deps.io.err(
|
|
204
|
+
'The server may have accepted this Notification Request before the connection failed. ' +
|
|
205
|
+
`Retry the same send with \`--idempotency-key ${idempotencyKey}\` so this Agent Event cannot create a duplicate.`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
if (err instanceof ApiCallError && err.code === 'no_active_devices') {
|
|
209
|
+
deps.io.err(
|
|
210
|
+
'After setup succeeds, repeat this original Notification Request with ' +
|
|
211
|
+
`\`--idempotency-key ${idempotencyKey}\`; the setup verification request does not replace this Agent Event.`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
return exit
|
|
187
215
|
}
|
|
188
216
|
const receiptExit = receiptExitCode(receipt)
|
|
189
217
|
// The single most useful line in the log: it ties the local invocation to
|
|
@@ -192,7 +220,7 @@ export async function sendCommand(
|
|
|
192
220
|
log(deps).info('send.submitted', {
|
|
193
221
|
request_id: receipt.request_id,
|
|
194
222
|
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
195
|
-
|
|
223
|
+
title_chars: flags.title.length,
|
|
196
224
|
overall: receipt.overall,
|
|
197
225
|
replayed: receipt.replayed,
|
|
198
226
|
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
@@ -220,23 +248,14 @@ export async function sendCommand(
|
|
|
220
248
|
receipt.overall === 'provider_accepted_all' &&
|
|
221
249
|
receipt.warnings.length === 0
|
|
222
250
|
if (!quietOrdinarySuccess) deps.io.out(formatReceipt(receipt))
|
|
223
|
-
}
|
|
251
|
+
}
|
|
224
252
|
|
|
225
253
|
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
226
254
|
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
227
255
|
if (flags.json) {
|
|
228
256
|
deps.io.out(
|
|
229
257
|
flags.reply
|
|
230
|
-
? JSON.stringify(
|
|
231
|
-
type: 'reply_result',
|
|
232
|
-
request_id: receipt.request_id,
|
|
233
|
-
replies: [],
|
|
234
|
-
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
235
|
-
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
236
|
-
agent_acknowledgement: null,
|
|
237
|
-
acknowledgement_command: null,
|
|
238
|
-
degraded: false,
|
|
239
|
-
})
|
|
258
|
+
? JSON.stringify(unansweredReplyResultJson(receipt, false))
|
|
240
259
|
: JSON.stringify(receipt, null, 2),
|
|
241
260
|
)
|
|
242
261
|
}
|
|
@@ -253,7 +272,7 @@ export async function sendCommand(
|
|
|
253
272
|
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
254
273
|
if (flags.json) {
|
|
255
274
|
deps.io.out(
|
|
256
|
-
JSON.stringify(replyResultJson(result.response, result.degraded)),
|
|
275
|
+
JSON.stringify(replyResultJson(result.response, result.degraded, receipt)),
|
|
257
276
|
)
|
|
258
277
|
} else if (result.response.replies.length > 0) {
|
|
259
278
|
printReplies(deps, result.response.replies)
|
|
@@ -282,9 +301,9 @@ export async function sendCommand(
|
|
|
282
301
|
}
|
|
283
302
|
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
284
303
|
} catch (err) {
|
|
285
|
-
//
|
|
286
|
-
// Permanent poll errors (auth, closed window, not found) still
|
|
287
|
-
// always name the durable request and point at recovery.
|
|
304
|
+
// The send already succeeded durably; a wait fault must not read as "send
|
|
305
|
+
// failed". Permanent poll errors (auth, closed window, not found) still
|
|
306
|
+
// surface, but always name the durable request and point at recovery.
|
|
288
307
|
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
289
308
|
log(deps).error('cli.error', {
|
|
290
309
|
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
@@ -297,6 +316,9 @@ export async function sendCommand(
|
|
|
297
316
|
`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
298
317
|
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
|
|
299
318
|
)
|
|
319
|
+
// JSON callers still get their one object: the durable receipt, no
|
|
320
|
+
// replies, and `degraded: true` — "could not find out", not "no answer".
|
|
321
|
+
if (flags.json) deps.io.out(JSON.stringify(unansweredReplyResultJson(receipt, true)))
|
|
300
322
|
if (err instanceof ApiCallError) {
|
|
301
323
|
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
302
324
|
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
@@ -370,19 +392,52 @@ export async function repliesCommand(
|
|
|
370
392
|
// Every delivered question in the session's queue, in registration order —
|
|
371
393
|
// an agent may have several outstanding at once.
|
|
372
394
|
const state = readSessionState(sessionId, deps.env)
|
|
373
|
-
|
|
395
|
+
const pending = Array.isArray(state.pending) ? state.pending : []
|
|
396
|
+
requestIds = pending
|
|
374
397
|
.map((entry) => entry.request_id)
|
|
375
398
|
.filter((id): id is string => id !== undefined)
|
|
399
|
+
const unsent = pending.filter((entry) => entry.request_id === undefined)
|
|
376
400
|
if (requestIds.length === 0) {
|
|
377
401
|
// `--json` is a promise about stdout, and an empty result is still a
|
|
378
402
|
// result: a caller that asked for machine-readable output must not have
|
|
379
403
|
// to parse an English sentence to learn there was nothing pending.
|
|
380
404
|
if (flags.json === true) {
|
|
381
|
-
deps.io.out(
|
|
405
|
+
deps.io.out(
|
|
406
|
+
JSON.stringify(
|
|
407
|
+
{
|
|
408
|
+
session_id: sessionId,
|
|
409
|
+
pending: unsent.map((entry) => ({
|
|
410
|
+
question: entry.question,
|
|
411
|
+
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
412
|
+
})),
|
|
413
|
+
replies: [],
|
|
414
|
+
},
|
|
415
|
+
null,
|
|
416
|
+
2,
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
} else if (unsent.length > 0) {
|
|
420
|
+
for (const entry of unsent) {
|
|
421
|
+
deps.io.out(
|
|
422
|
+
entry.question_id === undefined
|
|
423
|
+
? `unpushed question: ${entry.question}`
|
|
424
|
+
: `unpushed question ${entry.question_id}: ${entry.question}`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
deps.io.out('Retire one with `notifai close <question_id>` so a later Stop will not send it.')
|
|
382
428
|
} else {
|
|
383
429
|
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
384
430
|
}
|
|
385
|
-
return EXIT.noReply
|
|
431
|
+
return unsent.length > 0 ? EXIT.ok : EXIT.noReply
|
|
432
|
+
}
|
|
433
|
+
if (unsent.length > 0 && flags.json !== true) {
|
|
434
|
+
for (const entry of unsent) {
|
|
435
|
+
deps.io.out(
|
|
436
|
+
entry.question_id === undefined
|
|
437
|
+
? `unpushed question: ${entry.question}`
|
|
438
|
+
: `unpushed question ${entry.question_id}: ${entry.question}`,
|
|
439
|
+
)
|
|
440
|
+
}
|
|
386
441
|
}
|
|
387
442
|
}
|
|
388
443
|
if (requestIds.length === 0) {
|
|
@@ -477,9 +532,19 @@ function isNonNegativeInteger(value: number): boolean {
|
|
|
477
532
|
}
|
|
478
533
|
|
|
479
534
|
|
|
480
|
-
|
|
535
|
+
/**
|
|
536
|
+
* The one JSON object `send --reply --json` prints, whatever happens after the
|
|
537
|
+
* durable submit. Embedding the receipt keeps stdout a single parseable line;
|
|
538
|
+
* `replies` (from a wait) omits it because there is no submission to report.
|
|
539
|
+
*/
|
|
540
|
+
function replyResultJson(
|
|
541
|
+
response: ListRepliesResponse,
|
|
542
|
+
degraded: boolean,
|
|
543
|
+
receipt?: SubmissionReceipt,
|
|
544
|
+
): object {
|
|
481
545
|
return {
|
|
482
546
|
type: 'reply_result',
|
|
547
|
+
...(receipt !== undefined ? { receipt } : {}),
|
|
483
548
|
request_id: response.request_id,
|
|
484
549
|
reply_expires_at: response.reply_expires_at,
|
|
485
550
|
replies: response.replies,
|
|
@@ -497,6 +562,25 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
|
|
|
497
562
|
}
|
|
498
563
|
}
|
|
499
564
|
|
|
565
|
+
/**
|
|
566
|
+
* The reply_result for a reply send that has no answer to report — a receipt
|
|
567
|
+
* failure, or a wait that faulted. Same shape, same single line; `degraded`
|
|
568
|
+
* distinguishes "the wait could not find out" from "delivery already failed".
|
|
569
|
+
*/
|
|
570
|
+
function unansweredReplyResultJson(receipt: SubmissionReceipt, degraded: boolean): object {
|
|
571
|
+
return {
|
|
572
|
+
type: 'reply_result',
|
|
573
|
+
receipt,
|
|
574
|
+
request_id: receipt.request_id,
|
|
575
|
+
replies: [],
|
|
576
|
+
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
577
|
+
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
578
|
+
agent_acknowledgement: null,
|
|
579
|
+
acknowledgement_command: null,
|
|
580
|
+
degraded,
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
500
584
|
|
|
501
585
|
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
502
586
|
const logger = log(deps)
|
|
@@ -505,8 +589,8 @@ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly R
|
|
|
505
589
|
request_id: requestId,
|
|
506
590
|
sequence: reply.seq,
|
|
507
591
|
device: reply.device_name,
|
|
508
|
-
|
|
509
|
-
|
|
592
|
+
text_chars: reply.text.length,
|
|
593
|
+
answers_count: reply.answers?.length ?? 0,
|
|
510
594
|
source: reply.source,
|
|
511
595
|
})
|
|
512
596
|
}
|
|
@@ -565,11 +649,12 @@ export async function statusCommand(
|
|
|
565
649
|
if (!authed) return EXIT.auth
|
|
566
650
|
try {
|
|
567
651
|
const snapshot = await authed.client.evidence(requestId)
|
|
652
|
+
recordObservedDeliveryProof(deps, snapshot, config.project.value)
|
|
568
653
|
if (flags.json) {
|
|
569
654
|
deps.io.out(JSON.stringify(snapshot, null, 2))
|
|
570
655
|
return EXIT.ok
|
|
571
656
|
}
|
|
572
|
-
deps.io.out(`request ${snapshot.request_id}
|
|
657
|
+
deps.io.out(`request ${snapshot.request_id} — ${snapshot.overall}`)
|
|
573
658
|
let anyReplyReceived = false
|
|
574
659
|
for (const d of snapshot.deliveries) {
|
|
575
660
|
deps.io.out(` ${d.device_name}:`)
|
|
@@ -66,6 +66,40 @@ export function observedCompanionReceipt(
|
|
|
66
66
|
deviceId: string,
|
|
67
67
|
): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
|
|
68
68
|
const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId)
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
if (!delivery) return null
|
|
70
|
+
if (delivery.companion_receipt.state === 'observed' && delivery.companion_receipt.observed_at) {
|
|
71
|
+
return { delivery, observedAt: delivery.companion_receipt.observed_at }
|
|
72
|
+
}
|
|
73
|
+
const receipt = delivery.events.find((event) => event.stage === 'companion_received')
|
|
74
|
+
return receipt ? { delivery, observedAt: receipt.occurred_at } : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Replace an unknown setup proof after this age; recent in-flight proofs stay. */
|
|
78
|
+
export const SETUP_PROOF_STALE_MS = 24 * 60 * 60 * 1000
|
|
79
|
+
|
|
80
|
+
export function setupProofIsStale(proof: SetupProofRecord, now: number): boolean {
|
|
81
|
+
const started = Date.parse(proof.started_at)
|
|
82
|
+
return Number.isFinite(started) && now - started > SETUP_PROOF_STALE_MS
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Persist ordinary send/status Companion Receipts as the project's delivery
|
|
87
|
+
* proof so doctor does not demand a second verification request.
|
|
88
|
+
*/
|
|
89
|
+
export function recordObservedDeliveryProof(
|
|
90
|
+
deps: CommandDeps,
|
|
91
|
+
snapshot: EvidenceSnapshot,
|
|
92
|
+
project: string | null,
|
|
93
|
+
): boolean {
|
|
94
|
+
for (const delivery of snapshot.deliveries) {
|
|
95
|
+
const observed = observedCompanionReceipt(snapshot, delivery.device_id)
|
|
96
|
+
if (observed === null) continue
|
|
97
|
+
return writeSetupProof(deps, {
|
|
98
|
+
request_id: snapshot.request_id,
|
|
99
|
+
device_id: delivery.device_id,
|
|
100
|
+
project,
|
|
101
|
+
started_at: observed.observedAt,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
71
105
|
}
|
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
|
}
|