@raidiant/notifai 10.1.0 → 10.1.2
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 +37 -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 +9 -2
- 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 +254 -93
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.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-skill.js +72 -54
- package/dist/commands-skill.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/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.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/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +19 -0
- package/dist/node-floor.js +39 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- 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/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -38
- package/src/cli-bin.ts +0 -67
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -345
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -297
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -205
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -298
- package/src/commands-devices.test.ts +0 -15
- package/src/commands-devices.ts +0 -173
- package/src/commands-doctor.ts +0 -1440
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2268
- package/src/commands-init.ts +0 -878
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -769
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands.test.ts +0 -7828
- package/src/commands.ts +0 -75
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -410
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5549
- package/src/hooks.ts +0 -3759
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main.ts +0 -36
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -282
- package/src/program.ts +0 -818
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -179
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/skill.test.ts +0 -415
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/commands-send.ts
DELETED
|
@@ -1,769 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CAPABILITIES_V1,
|
|
3
|
-
REPLY_MAX_WINDOW_SECONDS,
|
|
4
|
-
validateDraft,
|
|
5
|
-
type ListRepliesResponse,
|
|
6
|
-
type ReplyView,
|
|
7
|
-
type SubmissionReceipt,
|
|
8
|
-
} from '@raidiant/notifai-protocol'
|
|
9
|
-
import { ApiCallError, NetworkError } from './client.js'
|
|
10
|
-
import type { FlagOverrides, loadConfig } from './config.js'
|
|
11
|
-
import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
|
|
12
|
-
import { enableProject, projectBinding } from './project-enablement.js'
|
|
13
|
-
import {
|
|
14
|
-
buildDraft,
|
|
15
|
-
formatReceipt,
|
|
16
|
-
receiptExitCode,
|
|
17
|
-
rejectAccidentalEscapedNewlines,
|
|
18
|
-
validateMediaInputs,
|
|
19
|
-
type SendFlags,
|
|
20
|
-
} from './send.js'
|
|
21
|
-
import { recordObservedDeliveryProof, setupProofProject } from './commands-setup-proof.js'
|
|
22
|
-
import {
|
|
23
|
-
EXIT,
|
|
24
|
-
authedClient,
|
|
25
|
-
loadLoggedConfig,
|
|
26
|
-
log,
|
|
27
|
-
reportError,
|
|
28
|
-
type CommandDeps,
|
|
29
|
-
} from './commands-core.js'
|
|
30
|
-
import { sourceContextHarnessSession } from './commands-harness-context.js'
|
|
31
|
-
import { resolveCommandSession } from './command-session.js'
|
|
32
|
-
import {
|
|
33
|
-
beginSendAttempt,
|
|
34
|
-
semanticMediaIds,
|
|
35
|
-
sendDraftFingerprint,
|
|
36
|
-
settleSendAttempt,
|
|
37
|
-
} from './send-attempts.js'
|
|
38
|
-
import {
|
|
39
|
-
acknowledgementCommand,
|
|
40
|
-
printAcknowledgementStatus,
|
|
41
|
-
resolveDraftInvocation,
|
|
42
|
-
uploadImage,
|
|
43
|
-
waitForReply,
|
|
44
|
-
} from './commands-send-support.js'
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// send / status
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export async function sendCommand(
|
|
52
|
-
deps: CommandDeps,
|
|
53
|
-
flags: SendFlags & {
|
|
54
|
-
json?: boolean
|
|
55
|
-
wait?: number
|
|
56
|
-
noWait?: boolean
|
|
57
|
-
replyTimeout?: number
|
|
58
|
-
idempotencyKey?: string
|
|
59
|
-
retry?: boolean
|
|
60
|
-
baseUrl?: string
|
|
61
|
-
},
|
|
62
|
-
): Promise<number> {
|
|
63
|
-
if (flags.retry === true && flags.idempotencyKey !== undefined) {
|
|
64
|
-
deps.io.err('Pass --retry or --idempotency-key, not both.')
|
|
65
|
-
return EXIT.usage
|
|
66
|
-
}
|
|
67
|
-
const hasChoice = Array.isArray(flags.choice)
|
|
68
|
-
? flags.choice.length > 0
|
|
69
|
-
: flags.choice !== undefined
|
|
70
|
-
if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
|
|
71
|
-
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
|
|
72
|
-
return EXIT.usage
|
|
73
|
-
}
|
|
74
|
-
// Kind now decides the sound a notification arrives with, so it is asked for
|
|
75
|
-
// at the boundary rather than defaulted silently: an unlabelled send would
|
|
76
|
-
// reach the user as ordinary news whatever actually happened.
|
|
77
|
-
if (!flags.reply && flags.kind === undefined) {
|
|
78
|
-
deps.io.err(
|
|
79
|
-
'--kind is required: say what this notification is. ' +
|
|
80
|
-
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed; no User reply would resume it). ' +
|
|
81
|
-
'--reply makes it a question without --kind.',
|
|
82
|
-
)
|
|
83
|
-
return EXIT.usage
|
|
84
|
-
}
|
|
85
|
-
if (!flags.reply && flags.kind === 'question') {
|
|
86
|
-
deps.io.err('--kind question requires --reply so the question can be answered.')
|
|
87
|
-
return EXIT.usage
|
|
88
|
-
}
|
|
89
|
-
if (
|
|
90
|
-
!flags.reply &&
|
|
91
|
-
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined || hasChoice)
|
|
92
|
-
) {
|
|
93
|
-
deps.io.err('Use --reply with --reply-timeout, --reply-window, or --choice.')
|
|
94
|
-
return EXIT.usage
|
|
95
|
-
}
|
|
96
|
-
const replyTimeout = flags.replyTimeout ?? 900
|
|
97
|
-
if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
|
|
98
|
-
deps.io.err('--reply-timeout must be a non-negative integer number of seconds.')
|
|
99
|
-
return EXIT.usage
|
|
100
|
-
}
|
|
101
|
-
// Asking while declaring that nothing will wait for the answer. The reply is
|
|
102
|
-
// captured server-side and then unreachable: only a blocking send waits for
|
|
103
|
-
// it, and the hook path drains questions registered by `ask`, never a send's
|
|
104
|
-
// request id. So the user gets a real button, taps it, and nothing happens —
|
|
105
|
-
// worse than a banner that never asked, because it spends their attention
|
|
106
|
-
// and their trust in the channel.
|
|
107
|
-
if (flags.reply && replyTimeout === 0) {
|
|
108
|
-
deps.io.err(
|
|
109
|
-
'A question needs someone to hear the answer, so --reply cannot be combined ' +
|
|
110
|
-
'with --reply-timeout 0.\n' +
|
|
111
|
-
'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
|
|
112
|
-
'To announce finished work, drop --reply and its choices.',
|
|
113
|
-
)
|
|
114
|
-
return EXIT.usage
|
|
115
|
-
}
|
|
116
|
-
if (
|
|
117
|
-
flags.reply &&
|
|
118
|
-
flags.replyWindow !== undefined &&
|
|
119
|
-
(!Number.isInteger(flags.replyWindow) ||
|
|
120
|
-
flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
|
|
121
|
-
flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
|
|
122
|
-
) {
|
|
123
|
-
deps.io.err(
|
|
124
|
-
`--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`,
|
|
125
|
-
)
|
|
126
|
-
return EXIT.usage
|
|
127
|
-
}
|
|
128
|
-
const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
|
|
129
|
-
if (escapedBody !== null) {
|
|
130
|
-
deps.io.err(escapedBody)
|
|
131
|
-
return EXIT.usage
|
|
132
|
-
}
|
|
133
|
-
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
134
|
-
if (mediaInputError !== null) {
|
|
135
|
-
deps.io.err(mediaInputError)
|
|
136
|
-
return EXIT.usage
|
|
137
|
-
}
|
|
138
|
-
const config = loadLoggedConfig(deps, {
|
|
139
|
-
cwd: deps.cwd,
|
|
140
|
-
env: deps.env,
|
|
141
|
-
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
|
|
142
|
-
})
|
|
143
|
-
const source = resolveDraftInvocation(
|
|
144
|
-
deps,
|
|
145
|
-
flags,
|
|
146
|
-
sourceContextHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
|
|
147
|
-
)
|
|
148
|
-
if (!source.ok) {
|
|
149
|
-
deps.io.err(source.error)
|
|
150
|
-
return EXIT.usage
|
|
151
|
-
}
|
|
152
|
-
let semanticImages: string[]
|
|
153
|
-
try {
|
|
154
|
-
semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
|
|
155
|
-
} catch (err) {
|
|
156
|
-
deps.io.err(`Could not fingerprint an image for safe retry: ${String(err)}`)
|
|
157
|
-
return EXIT.usage
|
|
158
|
-
}
|
|
159
|
-
const semanticBuild = buildDraft(
|
|
160
|
-
config,
|
|
161
|
-
{ ...flags, ...(semanticImages.length > 0 ? { image: semanticImages } : {}) },
|
|
162
|
-
source.invocation,
|
|
163
|
-
)
|
|
164
|
-
if (!semanticBuild.ok) {
|
|
165
|
-
deps.io.err(semanticBuild.error)
|
|
166
|
-
return EXIT.usage
|
|
167
|
-
}
|
|
168
|
-
if (semanticBuild.draft.project !== undefined) {
|
|
169
|
-
const binding = projectBinding(deps.cwd, deps.env, semanticBuild.draft.project)
|
|
170
|
-
if (binding !== null) enableProject(binding)
|
|
171
|
-
}
|
|
172
|
-
const authed = authedClient(deps, config)
|
|
173
|
-
if (!authed) return EXIT.auth
|
|
174
|
-
const credential = deps.store.load()!
|
|
175
|
-
const attempt = beginSendAttempt({
|
|
176
|
-
env: deps.env,
|
|
177
|
-
credential,
|
|
178
|
-
fingerprint: sendDraftFingerprint(semanticBuild.draft, credential),
|
|
179
|
-
retry: flags.retry === true,
|
|
180
|
-
...(flags.idempotencyKey === undefined ? {} : { idempotencyKey: flags.idempotencyKey }),
|
|
181
|
-
now: (deps.now ?? Date.now)(),
|
|
182
|
-
})
|
|
183
|
-
if (!attempt.ok) {
|
|
184
|
-
deps.io.err(`${attempt.code}: ${attempt.message}`)
|
|
185
|
-
return EXIT.usage
|
|
186
|
-
}
|
|
187
|
-
const mediaIds: string[] = []
|
|
188
|
-
for (const image of flags.image ?? []) {
|
|
189
|
-
if (image.startsWith('med_')) {
|
|
190
|
-
mediaIds.push(image)
|
|
191
|
-
continue
|
|
192
|
-
}
|
|
193
|
-
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
194
|
-
if (!uploaded.ok) {
|
|
195
|
-
settleSendAttempt(deps.env, attempt.attemptId)
|
|
196
|
-
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
197
|
-
return uploaded.exit
|
|
198
|
-
}
|
|
199
|
-
mediaIds.push(uploaded.mediaId)
|
|
200
|
-
}
|
|
201
|
-
flags = { ...flags, image: mediaIds }
|
|
202
|
-
const build = buildDraft(config, flags, source.invocation)
|
|
203
|
-
if (!build.ok) {
|
|
204
|
-
settleSendAttempt(deps.env, attempt.attemptId)
|
|
205
|
-
deps.io.err(build.error)
|
|
206
|
-
return EXIT.usage
|
|
207
|
-
}
|
|
208
|
-
const capabilities = CAPABILITIES_V1.describe(build.platform)
|
|
209
|
-
if (!capabilities) {
|
|
210
|
-
settleSendAttempt(deps.env, attempt.attemptId)
|
|
211
|
-
deps.io.err(`No capability contract is available for ${build.platform}.`)
|
|
212
|
-
return EXIT.usage
|
|
213
|
-
}
|
|
214
|
-
const validation = validateDraft(build.draft, capabilities)
|
|
215
|
-
if (!validation.ok) {
|
|
216
|
-
settleSendAttempt(deps.env, attempt.attemptId)
|
|
217
|
-
for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
|
|
218
|
-
return EXIT.usage
|
|
219
|
-
}
|
|
220
|
-
for (const warning of validation.warnings) {
|
|
221
|
-
deps.io.err(`Heads up (${warning.path}): ${warning.message}`)
|
|
222
|
-
}
|
|
223
|
-
emitSendWarnings(deps, flags, config)
|
|
224
|
-
if (
|
|
225
|
-
!flags.reply &&
|
|
226
|
-
(flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
|
|
227
|
-
) {
|
|
228
|
-
deps.io.err(
|
|
229
|
-
'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.',
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
233
|
-
const idempotencyKey = attempt.idempotencyKey
|
|
234
|
-
// Persist the retry identity before entering the ambiguous network boundary.
|
|
235
|
-
// If the process is killed after the server accepts but before a response,
|
|
236
|
-
// this is the only durable way to reconstruct the same Agent Event key.
|
|
237
|
-
log(deps).info('send.attempt', {
|
|
238
|
-
attempt_id: attempt.attemptId,
|
|
239
|
-
idempotency_key: idempotencyKey,
|
|
240
|
-
replay: attempt.replay,
|
|
241
|
-
})
|
|
242
|
-
let receipt: SubmissionReceipt
|
|
243
|
-
try {
|
|
244
|
-
receipt = await authed.client.submit(
|
|
245
|
-
{ idempotency_key: idempotencyKey, draft: build.draft },
|
|
246
|
-
waitSeconds,
|
|
247
|
-
)
|
|
248
|
-
} catch (err) {
|
|
249
|
-
const exit = reportError(deps, err)
|
|
250
|
-
if (exit === EXIT.network) {
|
|
251
|
-
deps.io.err(
|
|
252
|
-
'The server may have accepted this Notification Request before the connection failed. ' +
|
|
253
|
-
'Re-run the exact semantic send with `--retry`; the CLI will reuse its opaque attempt identity.',
|
|
254
|
-
)
|
|
255
|
-
}
|
|
256
|
-
if (err instanceof ApiCallError && err.code === 'no_active_devices') {
|
|
257
|
-
deps.io.err(
|
|
258
|
-
'After setup succeeds, repeat this exact original Notification Request with `--retry`; ' +
|
|
259
|
-
'the setup verification request does not replace this Agent Event.',
|
|
260
|
-
)
|
|
261
|
-
}
|
|
262
|
-
if (!(exit === EXIT.network || (err instanceof ApiCallError && err.code === 'no_active_devices'))) {
|
|
263
|
-
settleSendAttempt(deps.env, attempt.attemptId)
|
|
264
|
-
} else {
|
|
265
|
-
deps.io.err(`Opaque retry attempt: ${attempt.attemptId}. Re-run the exact semantic send with \`--retry\`; do not retry automatically.`)
|
|
266
|
-
}
|
|
267
|
-
return exit
|
|
268
|
-
}
|
|
269
|
-
settleSendAttempt(deps.env, attempt.attemptId)
|
|
270
|
-
const receiptExit = receiptExitCode(receipt)
|
|
271
|
-
// The single most useful line in the log: it ties the local invocation to
|
|
272
|
-
// the server-side request id, which is what every later question about this
|
|
273
|
-
// notification ("did it arrive?", "which device?") is asked in terms of.
|
|
274
|
-
log(deps).info('send.submitted', {
|
|
275
|
-
request_id: receipt.request_id,
|
|
276
|
-
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
277
|
-
title_chars: flags.title.length,
|
|
278
|
-
overall: receipt.overall,
|
|
279
|
-
replayed: receipt.replayed,
|
|
280
|
-
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
281
|
-
deliveries: receipt.deliveries.length,
|
|
282
|
-
wait_seconds: waitSeconds,
|
|
283
|
-
exit: receiptExit,
|
|
284
|
-
})
|
|
285
|
-
log(deps).info('send.outcome', {
|
|
286
|
-
request_id: receipt.request_id,
|
|
287
|
-
overall: receipt.overall,
|
|
288
|
-
// Per device, because "it said accepted but nothing arrived" is answered
|
|
289
|
-
// by which device reached which state and why the provider said so.
|
|
290
|
-
devices: receipt.deliveries.map((delivery) => ({
|
|
291
|
-
device: delivery.device_name,
|
|
292
|
-
state: delivery.state,
|
|
293
|
-
attempts: delivery.attempts,
|
|
294
|
-
provider_status: delivery.provider_status,
|
|
295
|
-
provider_reason: delivery.provider_reason,
|
|
296
|
-
})),
|
|
297
|
-
...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
|
|
298
|
-
})
|
|
299
|
-
if (!flags.json) {
|
|
300
|
-
const quietOrdinarySuccess =
|
|
301
|
-
!flags.reply &&
|
|
302
|
-
receipt.overall === 'provider_accepted_all' &&
|
|
303
|
-
receipt.warnings.length === 0
|
|
304
|
-
if (!quietOrdinarySuccess) deps.io.out(formatReceipt(receipt))
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
308
|
-
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
309
|
-
if (flags.json) {
|
|
310
|
-
deps.io.out(
|
|
311
|
-
flags.reply
|
|
312
|
-
? JSON.stringify(unansweredReplyResultJson(receipt, false))
|
|
313
|
-
: JSON.stringify(receipt, null, 2),
|
|
314
|
-
)
|
|
315
|
-
}
|
|
316
|
-
return receiptExit
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
321
|
-
timeoutSeconds: replyTimeout,
|
|
322
|
-
afterSeq: 0,
|
|
323
|
-
now: deps.now,
|
|
324
|
-
sleep: deps.sleep,
|
|
325
|
-
})
|
|
326
|
-
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
327
|
-
if (flags.json) {
|
|
328
|
-
deps.io.out(
|
|
329
|
-
JSON.stringify(replyResultJson(result.response, result.degraded, receipt)),
|
|
330
|
-
)
|
|
331
|
-
} else if (result.response.replies.length > 0) {
|
|
332
|
-
printReplies(deps, result.response.replies)
|
|
333
|
-
printAcknowledgementStatus(deps, result.response)
|
|
334
|
-
} else {
|
|
335
|
-
printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
|
|
336
|
-
printAcknowledgementStatus(deps, result.response)
|
|
337
|
-
}
|
|
338
|
-
if (result.degraded) {
|
|
339
|
-
log(deps).error('cli.error', {
|
|
340
|
-
kind: 'network',
|
|
341
|
-
operation: 'reply_wait',
|
|
342
|
-
request_id: receipt.request_id,
|
|
343
|
-
degraded: true,
|
|
344
|
-
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
345
|
-
})
|
|
346
|
-
deps.io.err(degradedWaitWarning(receipt.request_id))
|
|
347
|
-
return EXIT.network
|
|
348
|
-
}
|
|
349
|
-
if (result.timedOut) {
|
|
350
|
-
deps.io.err(
|
|
351
|
-
`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
352
|
-
`\`notifai close ${receipt.request_id}\`. ` +
|
|
353
|
-
`This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`,
|
|
354
|
-
)
|
|
355
|
-
}
|
|
356
|
-
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
357
|
-
} catch (err) {
|
|
358
|
-
// The send already succeeded durably; a wait fault must not read as "send
|
|
359
|
-
// failed". Permanent poll errors (auth, closed window, not found) still
|
|
360
|
-
// surface, but always name the durable request and point at recovery.
|
|
361
|
-
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
362
|
-
log(deps).error('cli.error', {
|
|
363
|
-
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
364
|
-
operation: 'reply_wait',
|
|
365
|
-
request_id: receipt.request_id,
|
|
366
|
-
...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
|
|
367
|
-
message: err.message,
|
|
368
|
-
})
|
|
369
|
-
deps.io.err(
|
|
370
|
-
`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
371
|
-
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
|
|
372
|
-
)
|
|
373
|
-
// JSON callers still get their one object: the durable receipt, no
|
|
374
|
-
// replies, and `degraded: true` — "could not find out", not "no answer".
|
|
375
|
-
if (flags.json) deps.io.out(JSON.stringify(unansweredReplyResultJson(receipt, true)))
|
|
376
|
-
if (err instanceof ApiCallError) {
|
|
377
|
-
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
378
|
-
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
379
|
-
? EXIT.network
|
|
380
|
-
: EXIT.failed
|
|
381
|
-
}
|
|
382
|
-
return EXIT.network
|
|
383
|
-
}
|
|
384
|
-
return reportError(deps, err)
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function emitSendWarnings(
|
|
389
|
-
deps: CommandDeps,
|
|
390
|
-
flags: SendFlags,
|
|
391
|
-
config: ReturnType<typeof loadConfig>,
|
|
392
|
-
): void {
|
|
393
|
-
if (flags.title.length > 40) {
|
|
394
|
-
deps.io.err(
|
|
395
|
-
`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
|
|
396
|
-
)
|
|
397
|
-
}
|
|
398
|
-
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
399
|
-
deps.io.err(
|
|
400
|
-
'Heads up: keep the title to the specific substance. Put notification type in --kind; Project identity is inferred separately.',
|
|
401
|
-
)
|
|
402
|
-
}
|
|
403
|
-
if (
|
|
404
|
-
flags.collapseKey === undefined &&
|
|
405
|
-
config.collapse_key.value !== null &&
|
|
406
|
-
config.collapse_key.source.startsWith('global:')
|
|
407
|
-
) {
|
|
408
|
-
deps.io.err(
|
|
409
|
-
'Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.',
|
|
410
|
-
)
|
|
411
|
-
}
|
|
412
|
-
if (flags.ttl !== undefined && flags.ttl > 259_200) {
|
|
413
|
-
deps.io.err(
|
|
414
|
-
'Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.',
|
|
415
|
-
)
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export async function repliesCommand(
|
|
420
|
-
deps: CommandDeps,
|
|
421
|
-
requestedId: string | undefined,
|
|
422
|
-
flags: { wait?: number; after?: number; json?: boolean; pending?: boolean },
|
|
423
|
-
): Promise<number> {
|
|
424
|
-
const waitSeconds = flags.wait ?? 0
|
|
425
|
-
const afterSeq = flags.after ?? 0
|
|
426
|
-
if (!isNonNegativeInteger(waitSeconds)) {
|
|
427
|
-
deps.io.err('--wait must be a non-negative integer number of seconds.')
|
|
428
|
-
return EXIT.usage
|
|
429
|
-
}
|
|
430
|
-
if (!isNonNegativeInteger(afterSeq)) {
|
|
431
|
-
deps.io.err('--after must be a non-negative integer sequence number.')
|
|
432
|
-
return EXIT.usage
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (flags.pending === true && requestedId !== undefined) {
|
|
436
|
-
deps.io.err('Pass a request id or --pending, not both.')
|
|
437
|
-
return EXIT.usage
|
|
438
|
-
}
|
|
439
|
-
let requestIds = requestedId === undefined ? [] : [requestedId]
|
|
440
|
-
let lifecycleSessionId: string | undefined
|
|
441
|
-
if (flags.pending === true) {
|
|
442
|
-
const session = resolveCommandSession(deps)
|
|
443
|
-
if (session === null) {
|
|
444
|
-
deps.io.err('No active session pointer is available in this directory.')
|
|
445
|
-
return EXIT.noReply
|
|
446
|
-
}
|
|
447
|
-
const sessionId = session.sessionId
|
|
448
|
-
lifecycleSessionId = sessionId
|
|
449
|
-
// Every delivered question in the session's queue, in registration order —
|
|
450
|
-
// an agent may have several outstanding at once.
|
|
451
|
-
const state = readSessionState(sessionId, deps.env)
|
|
452
|
-
const pending = Array.isArray(state.pending) ? state.pending : []
|
|
453
|
-
requestIds = pending
|
|
454
|
-
.map((entry) => entry.request_id)
|
|
455
|
-
.filter((id): id is string => id !== undefined)
|
|
456
|
-
const unsent = pending.filter((entry) => entry.request_id === undefined)
|
|
457
|
-
if (requestIds.length === 0) {
|
|
458
|
-
// `--json` is a promise about stdout, and an empty result is still a
|
|
459
|
-
// result: a caller that asked for machine-readable output must not have
|
|
460
|
-
// to parse an English sentence to learn there was nothing pending.
|
|
461
|
-
if (flags.json === true) {
|
|
462
|
-
deps.io.out(
|
|
463
|
-
JSON.stringify(
|
|
464
|
-
{
|
|
465
|
-
session_id: sessionId,
|
|
466
|
-
pending: unsent.map((entry) => ({
|
|
467
|
-
question: entry.question,
|
|
468
|
-
...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
|
|
469
|
-
})),
|
|
470
|
-
replies: [],
|
|
471
|
-
},
|
|
472
|
-
null,
|
|
473
|
-
2,
|
|
474
|
-
),
|
|
475
|
-
)
|
|
476
|
-
} else if (unsent.length > 0) {
|
|
477
|
-
for (const entry of unsent) {
|
|
478
|
-
deps.io.out(
|
|
479
|
-
entry.question_id === undefined
|
|
480
|
-
? `unpushed question: ${entry.question}`
|
|
481
|
-
: `unpushed question ${entry.question_id}: ${entry.question}`,
|
|
482
|
-
)
|
|
483
|
-
}
|
|
484
|
-
deps.io.out('Retire one with `notifai close <question_id>` so a later Stop will not send it.')
|
|
485
|
-
} else {
|
|
486
|
-
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
487
|
-
}
|
|
488
|
-
return unsent.length > 0 ? EXIT.ok : EXIT.noReply
|
|
489
|
-
}
|
|
490
|
-
if (unsent.length > 0 && flags.json !== true) {
|
|
491
|
-
for (const entry of unsent) {
|
|
492
|
-
deps.io.out(
|
|
493
|
-
entry.question_id === undefined
|
|
494
|
-
? `unpushed question: ${entry.question}`
|
|
495
|
-
: `unpushed question ${entry.question_id}: ${entry.question}`,
|
|
496
|
-
)
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (requestIds.length === 0) {
|
|
501
|
-
deps.io.err('Pass a request id or --pending.')
|
|
502
|
-
return EXIT.usage
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const config = loadLoggedConfig(deps, {
|
|
506
|
-
cwd: deps.cwd,
|
|
507
|
-
env: deps.env,
|
|
508
|
-
...(lifecycleSessionId === undefined ? {} : { sessionId: lifecycleSessionId }),
|
|
509
|
-
})
|
|
510
|
-
const authed = authedClient(deps, config)
|
|
511
|
-
if (!authed) return EXIT.auth
|
|
512
|
-
try {
|
|
513
|
-
let anyReplies = false
|
|
514
|
-
const degradedRequestIds: string[] = []
|
|
515
|
-
let allTimedOut = true
|
|
516
|
-
const jsonBodies: object[] = []
|
|
517
|
-
for (const requestId of requestIds) {
|
|
518
|
-
try {
|
|
519
|
-
const result = await waitForReply(authed.client, requestId, {
|
|
520
|
-
timeoutSeconds: waitSeconds,
|
|
521
|
-
afterSeq,
|
|
522
|
-
now: deps.now,
|
|
523
|
-
sleep: deps.sleep,
|
|
524
|
-
})
|
|
525
|
-
recordReplies(deps, requestId, result.response.replies)
|
|
526
|
-
if (flags.json) {
|
|
527
|
-
jsonBodies.push(replyResultJson(result.response, result.degraded))
|
|
528
|
-
} else if (result.response.replies.length > 0) {
|
|
529
|
-
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
530
|
-
printReplies(deps, result.response.replies)
|
|
531
|
-
printAcknowledgementStatus(deps, result.response)
|
|
532
|
-
} else {
|
|
533
|
-
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
534
|
-
printAcknowledgementStatus(deps, result.response)
|
|
535
|
-
}
|
|
536
|
-
anyReplies ||= result.response.replies.length > 0
|
|
537
|
-
if (result.degraded) degradedRequestIds.push(requestId)
|
|
538
|
-
allTimedOut &&= result.timedOut
|
|
539
|
-
} catch (err) {
|
|
540
|
-
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
541
|
-
return reportError(deps, err, { operation: 'reply_wait', request_id: requestId })
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
if (flags.json) {
|
|
545
|
-
// One request keeps the response shape agents already parse; several
|
|
546
|
-
// (only possible via --pending) arrive as an array in queue order.
|
|
547
|
-
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
|
|
548
|
-
}
|
|
549
|
-
if (degradedRequestIds.length > 0) {
|
|
550
|
-
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
551
|
-
// Name a request whose polls actually degraded, not merely the first item
|
|
552
|
-
// in a multi-request queue that may have completed cleanly.
|
|
553
|
-
deps.io.err(degradedWaitWarning(degradedRequestIds[0]!))
|
|
554
|
-
return EXIT.network
|
|
555
|
-
}
|
|
556
|
-
if (anyReplies) return EXIT.ok
|
|
557
|
-
return allTimedOut ? EXIT.noReply : EXIT.ok
|
|
558
|
-
} catch (err) {
|
|
559
|
-
return reportError(deps, err)
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
function recordDegradedReplyWaits(deps: CommandDeps, requestIds: readonly string[]): void {
|
|
565
|
-
if (requestIds.length === 0) return
|
|
566
|
-
log(deps).error('cli.error', {
|
|
567
|
-
kind: 'network',
|
|
568
|
-
operation: 'reply_wait',
|
|
569
|
-
request_ids: requestIds,
|
|
570
|
-
degraded: true,
|
|
571
|
-
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
572
|
-
})
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
577
|
-
* not find out" must not look the same, because agents branch on the exit code
|
|
578
|
-
* and one of those two branches is safe to proceed from.
|
|
579
|
-
*
|
|
580
|
-
* Delivery / Companion Receipt / OS presentation are separate facts — a wait
|
|
581
|
-
* fault is not evidence that the push failed.
|
|
582
|
-
*/
|
|
583
|
-
function degradedWaitWarning(requestId: string): string {
|
|
584
|
-
return (
|
|
585
|
-
`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
|
|
586
|
-
`so this is "could not find out", not "no answer" — the request is still durable and the ` +
|
|
587
|
-
`reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
|
|
588
|
-
`Retry with \`notifai replies ${requestId}\`.`
|
|
589
|
-
)
|
|
590
|
-
}
|
|
591
|
-
function isNonNegativeInteger(value: number): boolean {
|
|
592
|
-
return Number.isInteger(value) && value >= 0
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* The one JSON object `send --reply --json` prints, whatever happens after the
|
|
598
|
-
* durable submit. Embedding the receipt keeps stdout a single parseable line;
|
|
599
|
-
* `replies` (from a wait) omits it because there is no submission to report.
|
|
600
|
-
*/
|
|
601
|
-
function replyResultJson(
|
|
602
|
-
response: ListRepliesResponse,
|
|
603
|
-
degraded: boolean,
|
|
604
|
-
receipt?: SubmissionReceipt,
|
|
605
|
-
): object {
|
|
606
|
-
return {
|
|
607
|
-
type: 'reply_result',
|
|
608
|
-
...(receipt !== undefined ? { receipt } : {}),
|
|
609
|
-
request_id: response.request_id,
|
|
610
|
-
reply_expires_at: response.reply_expires_at,
|
|
611
|
-
replies: response.replies,
|
|
612
|
-
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
613
|
-
agent_acknowledgement: response.agent_acknowledgement,
|
|
614
|
-
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
615
|
-
acknowledgement_command: acknowledgementCommand(
|
|
616
|
-
response.request_id,
|
|
617
|
-
response.agent_acknowledgement_required,
|
|
618
|
-
response.agent_acknowledgement_text_required,
|
|
619
|
-
response.agent_acknowledgement,
|
|
620
|
-
response.replies.length > 0,
|
|
621
|
-
),
|
|
622
|
-
degraded,
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* The reply_result for a reply send that has no answer to report — a receipt
|
|
628
|
-
* failure, or a wait that faulted. Same shape, same single line; `degraded`
|
|
629
|
-
* distinguishes "the wait could not find out" from "delivery already failed".
|
|
630
|
-
*/
|
|
631
|
-
function unansweredReplyResultJson(receipt: SubmissionReceipt, degraded: boolean): object {
|
|
632
|
-
return {
|
|
633
|
-
type: 'reply_result',
|
|
634
|
-
receipt,
|
|
635
|
-
request_id: receipt.request_id,
|
|
636
|
-
replies: [],
|
|
637
|
-
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
638
|
-
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
639
|
-
agent_acknowledgement: null,
|
|
640
|
-
acknowledgement_command: null,
|
|
641
|
-
degraded,
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
647
|
-
const logger = log(deps)
|
|
648
|
-
for (const reply of replies) {
|
|
649
|
-
logger.info('reply.received', {
|
|
650
|
-
request_id: requestId,
|
|
651
|
-
sequence: reply.seq,
|
|
652
|
-
device: reply.device_name,
|
|
653
|
-
text_chars: reply.text.length,
|
|
654
|
-
answers_count: reply.answers?.length ?? 0,
|
|
655
|
-
source: reply.source,
|
|
656
|
-
})
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
|
|
661
|
-
for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
|
|
662
|
-
const contradiction = contradictingAnswer(replies)
|
|
663
|
-
if (contradiction !== null) deps.io.err(contradiction)
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* The correction note for a wait that saw more than one answer.
|
|
668
|
-
*
|
|
669
|
-
* A later reply that conflicts with an earlier one is a correction, so the
|
|
670
|
-
* latest answer is the one that counts. The server retires the question on
|
|
671
|
-
* the other devices the moment one answers, which makes this rare; it is
|
|
672
|
-
* still reachable, because a device can answer between the first reply
|
|
673
|
-
* landing and its retirement arriving.
|
|
674
|
-
*
|
|
675
|
-
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
676
|
-
*/
|
|
677
|
-
export function contradictingAnswer(replies: ReplyView[]): string | null {
|
|
678
|
-
const winner = replies.at(-1)
|
|
679
|
-
if (winner === undefined || replies.length < 2) return null
|
|
680
|
-
const key = (reply: ReplyView) =>
|
|
681
|
-
reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text
|
|
682
|
-
const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner))
|
|
683
|
-
if (corrected.length === 0) return null
|
|
684
|
-
const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ')
|
|
685
|
-
return (
|
|
686
|
-
`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
|
|
687
|
-
`answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`
|
|
688
|
-
)
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
function printNoReply(deps: CommandDeps, requestId: string, expiresAt?: string | null): void {
|
|
692
|
-
// A harness hook may have retired this question, in which case promising an
|
|
693
|
-
// open window would send the caller back to wait for an answer the server
|
|
694
|
-
// will now refuse.
|
|
695
|
-
const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now()
|
|
696
|
-
deps.io.out(
|
|
697
|
-
closed
|
|
698
|
-
? `no reply for request ${requestId}; the reply window has closed`
|
|
699
|
-
: `no reply yet for request ${requestId}; the reply window remains open`,
|
|
700
|
-
)
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
export async function statusCommand(
|
|
704
|
-
deps: CommandDeps,
|
|
705
|
-
requestId: string,
|
|
706
|
-
flags: { json?: boolean },
|
|
707
|
-
): Promise<number> {
|
|
708
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
709
|
-
const authed = authedClient(deps, config)
|
|
710
|
-
if (!authed) return EXIT.auth
|
|
711
|
-
try {
|
|
712
|
-
const snapshot = await authed.client.evidence(requestId)
|
|
713
|
-
recordObservedDeliveryProof(deps, snapshot, setupProofProject(deps, config.project.value))
|
|
714
|
-
if (flags.json) {
|
|
715
|
-
deps.io.out(JSON.stringify(snapshot, null, 2))
|
|
716
|
-
return EXIT.ok
|
|
717
|
-
}
|
|
718
|
-
deps.io.out(`request ${snapshot.request_id} — ${snapshot.overall}`)
|
|
719
|
-
let anyReplyReceived = false
|
|
720
|
-
for (const d of snapshot.deliveries) {
|
|
721
|
-
deps.io.out(` ${d.device_name}:`)
|
|
722
|
-
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
|
|
723
|
-
deps.io.out(
|
|
724
|
-
` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`,
|
|
725
|
-
)
|
|
726
|
-
if (d.companion_receipt.state === 'observed') {
|
|
727
|
-
const latency = d.companion_receipt.latency_ms
|
|
728
|
-
deps.io.out(
|
|
729
|
-
` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
|
|
730
|
-
(latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
|
|
731
|
-
)
|
|
732
|
-
} else {
|
|
733
|
-
deps.io.out(
|
|
734
|
-
" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
|
|
735
|
-
)
|
|
736
|
-
}
|
|
737
|
-
// Notifai never learns whether the OS painted a banner; saying so stops
|
|
738
|
-
// a reply-wait fault from being misread as "the phone never showed it".
|
|
739
|
-
deps.io.out(
|
|
740
|
-
' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown',
|
|
741
|
-
)
|
|
742
|
-
const replyEvent = d.events.find((e) => e.stage === 'reply_received')
|
|
743
|
-
if (replyEvent) {
|
|
744
|
-
anyReplyReceived = true
|
|
745
|
-
deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`)
|
|
746
|
-
} else {
|
|
747
|
-
deps.io.out(' Reply received: not yet recorded on this delivery')
|
|
748
|
-
}
|
|
749
|
-
for (const e of d.events) {
|
|
750
|
-
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
deps.io.out(
|
|
754
|
-
anyReplyReceived
|
|
755
|
-
? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
|
|
756
|
-
: ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``,
|
|
757
|
-
)
|
|
758
|
-
return EXIT.ok
|
|
759
|
-
} catch (err) {
|
|
760
|
-
return reportError(deps, err)
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function formatElapsed(milliseconds: number): string {
|
|
765
|
-
const totalSeconds = Math.floor(milliseconds / 1_000)
|
|
766
|
-
const minutes = Math.floor(totalSeconds / 60)
|
|
767
|
-
const seconds = totalSeconds % 60
|
|
768
|
-
return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`
|
|
769
|
-
}
|