@raidiant/notifai 7.0.2 → 8.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 +34 -0
- 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.js +14 -12
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +23 -0
- package/dist/commands-guidance.js +128 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +7 -1
- package/dist/commands-hooks.js +3 -6
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +25 -22
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +5 -3
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +45 -28
- package/dist/commands-send.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +6 -15
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +1 -3
- package/dist/config.js +0 -3
- package/dist/config.js.map +1 -1
- package/dist/guidance-content.d.ts +30 -0
- package/dist/guidance-content.js +175 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +48 -0
- package/dist/guidance.js +118 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hooks.js +7 -7
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +13 -8
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +3 -474
- package/dist/main.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +571 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +4 -9
- package/dist/send.js +48 -28
- package/dist/send.js.map +1 -1
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/package.json +5 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +17 -12
- package/src/commands-guidance.ts +170 -0
- package/src/commands-hooks.ts +10 -8
- package/src/commands-init.ts +31 -22
- package/src/commands-send-support.ts +5 -3
- package/src/commands-send.ts +50 -28
- package/src/commands.test.ts +374 -63
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +6 -17
- package/src/config.test.ts +56 -1
- package/src/config.ts +0 -5
- package/src/guidance-content.ts +190 -0
- package/src/guidance.test.ts +268 -0
- package/src/guidance.ts +151 -0
- package/src/hooks.test.ts +37 -5
- package/src/hooks.ts +7 -7
- package/src/interactive.ts +13 -8
- package/src/main.ts +4 -600
- package/src/program.test.ts +202 -0
- package/src/program.ts +723 -0
- package/src/send.ts +53 -32
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +37 -12
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { describeSource } from './config-schema.js'
|
|
4
|
+
import { EXIT, type CommandDeps } from './commands-core.js'
|
|
5
|
+
import { SHIPPED_GUIDANCE, shippedGuidanceTopic } from './guidance-content.js'
|
|
6
|
+
import {
|
|
7
|
+
GUIDANCE_TOPIC_MAX_BYTES,
|
|
8
|
+
GUIDANCE_TOPIC_PATTERN,
|
|
9
|
+
findProjectGuidanceDir,
|
|
10
|
+
globalGuidanceDir,
|
|
11
|
+
personalProjectGuidanceDir,
|
|
12
|
+
resolveGuidance,
|
|
13
|
+
} from './guidance.js'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// guidance show / set / unset
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The whole effective guidance, ready to be followed as read. Each topic is
|
|
21
|
+
* printed under a provenance comment so a reader can tell the user's standing
|
|
22
|
+
* word from the shipped default without the output stopping being Markdown.
|
|
23
|
+
*/
|
|
24
|
+
export function guidanceShowCommand(deps: CommandDeps, flags: { json?: boolean }): number {
|
|
25
|
+
const topics = resolveGuidance({ cwd: deps.cwd, env: deps.env })
|
|
26
|
+
if (flags.json) {
|
|
27
|
+
deps.io.out(
|
|
28
|
+
JSON.stringify(
|
|
29
|
+
topics.map((topic) => ({
|
|
30
|
+
name: topic.name,
|
|
31
|
+
source: topic.source,
|
|
32
|
+
summary: topic.summary,
|
|
33
|
+
content: topic.content,
|
|
34
|
+
})),
|
|
35
|
+
null,
|
|
36
|
+
2,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
return EXIT.ok
|
|
40
|
+
}
|
|
41
|
+
const blocks = topics.map((topic) => {
|
|
42
|
+
const { label, path: filePath } = describeSource(topic.source)
|
|
43
|
+
const origin = topic.source === 'default' ? 'shipped default' : label
|
|
44
|
+
const provenance = filePath === null ? origin : `${origin} · ${filePath}`
|
|
45
|
+
return `<!-- ${topic.name} · ${provenance} -->\n${topic.content.trimEnd()}`
|
|
46
|
+
})
|
|
47
|
+
deps.io.out(blocks.join('\n\n'))
|
|
48
|
+
return EXIT.ok
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type GuidanceMutationFlags = {
|
|
52
|
+
project?: boolean | undefined
|
|
53
|
+
local?: boolean | undefined
|
|
54
|
+
yes?: boolean | undefined
|
|
55
|
+
}
|
|
56
|
+
type GuidanceLayer = 'global' | 'project' | 'local'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a topic file for one layer lives. The project layer prefers an
|
|
60
|
+
* existing `.notifai/guidance` up the tree and otherwise starts one at cwd,
|
|
61
|
+
* the same anchoring `config set --project` uses for its file.
|
|
62
|
+
*/
|
|
63
|
+
function guidanceLayerDir(deps: CommandDeps, layer: GuidanceLayer): string {
|
|
64
|
+
if (layer === 'local') return personalProjectGuidanceDir(deps.cwd, deps.env)
|
|
65
|
+
if (layer === 'project') {
|
|
66
|
+
return findProjectGuidanceDir(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'guidance')
|
|
67
|
+
}
|
|
68
|
+
return globalGuidanceDir(deps.env)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function guidanceMutationTarget(
|
|
72
|
+
deps: CommandDeps,
|
|
73
|
+
topic: string,
|
|
74
|
+
flags: GuidanceMutationFlags,
|
|
75
|
+
): Promise<string | null> {
|
|
76
|
+
if (!GUIDANCE_TOPIC_PATTERN.test(topic)) {
|
|
77
|
+
deps.io.err(
|
|
78
|
+
`"${topic}" is not a topic name. Use lowercase letters, digits, "-" and "_", e.g. ${SHIPPED_GUIDANCE.map((t) => t.name).join(', ')}.`,
|
|
79
|
+
)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
let layer: GuidanceLayer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
83
|
+
if (
|
|
84
|
+
flags.local !== true &&
|
|
85
|
+
flags.project !== true &&
|
|
86
|
+
flags.yes !== true &&
|
|
87
|
+
deps.io.interactive === true &&
|
|
88
|
+
deps.io.select
|
|
89
|
+
) {
|
|
90
|
+
const selected = await deps.io.select('Where should this guidance live?', [
|
|
91
|
+
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
92
|
+
{ value: 'project', label: 'This project (shared)', hint: '.notifai/guidance/' },
|
|
93
|
+
{ value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
|
|
94
|
+
])
|
|
95
|
+
if (selected === null) {
|
|
96
|
+
deps.io.err('No guidance layer selected.')
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
layer = selected as GuidanceLayer
|
|
100
|
+
}
|
|
101
|
+
return path.join(guidanceLayerDir(deps, layer), `${topic}.md`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Writes one topic at one layer, replacing the shipped topic of that name for
|
|
106
|
+
* this scope. The confirmation gate is the same one `config set` holds: the
|
|
107
|
+
* user owns their standing word, so an agent writes it only with their words
|
|
108
|
+
* and their say-so.
|
|
109
|
+
*/
|
|
110
|
+
export async function guidanceSetCommand(
|
|
111
|
+
deps: CommandDeps,
|
|
112
|
+
topic: string,
|
|
113
|
+
text: string,
|
|
114
|
+
flags: GuidanceMutationFlags,
|
|
115
|
+
): Promise<number> {
|
|
116
|
+
if (text.trim() === '') {
|
|
117
|
+
deps.io.err('Guidance text is empty. Pass the text, or --file <path|-> to read it.')
|
|
118
|
+
return EXIT.usage
|
|
119
|
+
}
|
|
120
|
+
if (text.length > GUIDANCE_TOPIC_MAX_BYTES) {
|
|
121
|
+
deps.io.err(`Guidance for one topic is capped at ${GUIDANCE_TOPIC_MAX_BYTES} bytes.`)
|
|
122
|
+
return EXIT.usage
|
|
123
|
+
}
|
|
124
|
+
const target = await guidanceMutationTarget(deps, topic, flags)
|
|
125
|
+
if (target === null) return EXIT.usage
|
|
126
|
+
|
|
127
|
+
const shipped = shippedGuidanceTopic(topic)
|
|
128
|
+
if (!flags.yes) {
|
|
129
|
+
const replaces =
|
|
130
|
+
shipped === undefined
|
|
131
|
+
? `Add guidance topic "${topic}"`
|
|
132
|
+
: `Replace the shipped "${topic}" guidance for this scope`
|
|
133
|
+
const confirmed = await deps.io.confirm(`${replaces} at ${target}?`)
|
|
134
|
+
if (!confirmed) {
|
|
135
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
136
|
+
return EXIT.usage
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
141
|
+
writeFileSync(target, text.endsWith('\n') ? text : `${text}\n`)
|
|
142
|
+
deps.io.out(`Wrote ${topic} to ${target}`)
|
|
143
|
+
if (shipped !== undefined) {
|
|
144
|
+
deps.io.out(`This file now replaces the shipped "${topic}" guidance wherever it applies.`)
|
|
145
|
+
}
|
|
146
|
+
return EXIT.ok
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function guidanceUnsetCommand(
|
|
150
|
+
deps: CommandDeps,
|
|
151
|
+
topic: string,
|
|
152
|
+
flags: GuidanceMutationFlags,
|
|
153
|
+
): Promise<number> {
|
|
154
|
+
const target = await guidanceMutationTarget(deps, topic, flags)
|
|
155
|
+
if (target === null) return EXIT.usage
|
|
156
|
+
if (!existsSync(target)) {
|
|
157
|
+
deps.io.out(`${topic} is not set at ${target}`)
|
|
158
|
+
return EXIT.ok
|
|
159
|
+
}
|
|
160
|
+
if (!flags.yes) {
|
|
161
|
+
const confirmed = await deps.io.confirm(`Remove ${target}?`)
|
|
162
|
+
if (!confirmed) {
|
|
163
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
164
|
+
return EXIT.usage
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
rmSync(target, { force: true })
|
|
168
|
+
deps.io.out(`Removed ${target}; the inherited guidance now applies.`)
|
|
169
|
+
return EXIT.ok
|
|
170
|
+
}
|
package/src/commands-hooks.ts
CHANGED
|
@@ -460,7 +460,13 @@ interface AskFormQuestion {
|
|
|
460
460
|
|
|
461
461
|
export interface BuiltQuestions {
|
|
462
462
|
questions: QuestionT[]
|
|
463
|
-
/**
|
|
463
|
+
/**
|
|
464
|
+
* Canonical Markdown body. The question already travels as the notification
|
|
465
|
+
* title and as structured questions, so the body carries only the context —
|
|
466
|
+
* repeating the question there put it on the lock screen and the reply
|
|
467
|
+
* screen twice. Only when there is no context does the question text stand
|
|
468
|
+
* in, because the wire requires a body.
|
|
469
|
+
*/
|
|
464
470
|
body: string
|
|
465
471
|
}
|
|
466
472
|
|
|
@@ -524,14 +530,13 @@ export function buildQuestions(
|
|
|
524
530
|
questions.push(built.question)
|
|
525
531
|
}
|
|
526
532
|
const context = flags.body ?? (record['body'] as string | undefined)
|
|
527
|
-
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
|
|
528
533
|
return {
|
|
529
534
|
ok: true,
|
|
530
535
|
questions,
|
|
531
536
|
body:
|
|
532
537
|
context !== undefined && context.trim() !== ''
|
|
533
|
-
?
|
|
534
|
-
:
|
|
538
|
+
? context
|
|
539
|
+
: questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n'),
|
|
535
540
|
}
|
|
536
541
|
}
|
|
537
542
|
|
|
@@ -544,10 +549,7 @@ export function buildQuestions(
|
|
|
544
549
|
return {
|
|
545
550
|
ok: true,
|
|
546
551
|
questions: [built.question],
|
|
547
|
-
body:
|
|
548
|
-
context !== undefined && context.trim() !== ''
|
|
549
|
-
? `${built.question.text}\n\n${context}`
|
|
550
|
-
: built.question.text,
|
|
552
|
+
body: context !== undefined && context.trim() !== '' ? context : built.question.text,
|
|
551
553
|
}
|
|
552
554
|
}
|
|
553
555
|
|
package/src/commands-init.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
reportError,
|
|
24
24
|
type CommandDeps,
|
|
25
25
|
} from './commands-core.js'
|
|
26
|
-
import {
|
|
26
|
+
import { readyCompanionDevices, supportPageUrl } from './commands-devices.js'
|
|
27
27
|
import { assessReadiness, remedyLine } from './commands-doctor.js'
|
|
28
28
|
import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
|
|
29
29
|
import { observedCompanionReceipt, readSetupProof, writeSetupProof } from './commands-setup-proof.js'
|
|
@@ -55,7 +55,7 @@ export interface InitFlags {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
/** Long enough for a first
|
|
58
|
+
/** Long enough for a first controlled Companion install; keep-waiting extends another budget. */
|
|
59
59
|
const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
|
|
60
60
|
const DEVICE_BRIDGE_POLL_MS = 2_000
|
|
61
61
|
const PROOF_TIMEOUT_MS = 30_000
|
|
@@ -159,20 +159,21 @@ async function closeGap(
|
|
|
159
159
|
|
|
160
160
|
function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
|
|
161
161
|
if (devices.length === 0) {
|
|
162
|
-
return 'Waiting for
|
|
162
|
+
return 'Waiting for a Companion App to sign in and register…'
|
|
163
163
|
}
|
|
164
164
|
const denied = devices.find((device) => device.permission_status === 'denied')
|
|
165
165
|
if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
|
|
166
166
|
const undecided = devices.find((device) => device.permission_status === 'not_determined')
|
|
167
167
|
if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
|
|
168
|
-
return 'Waiting for
|
|
168
|
+
return 'Waiting for a Companion device to become ready…'
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
172
|
* Observe the supported Device Installation path while the user finishes the
|
|
173
|
-
* app-side work. The live bridge is the dashboard `/support` page (
|
|
174
|
-
* steps). Interactive runs offer to open it so the user
|
|
175
|
-
* non-interactive/agent paths only print plain text and
|
|
173
|
+
* app-side work. The live bridge is the dashboard `/support` page (controlled
|
|
174
|
+
* Companion installation steps). Interactive runs offer to open it so the user
|
|
175
|
+
* never types the URL; non-interactive/agent paths only print plain text and
|
|
176
|
+
* never wait on a prompt.
|
|
176
177
|
*
|
|
177
178
|
* The wait budget is stated up front. On expiry we say the *timer* expired —
|
|
178
179
|
* not the setup — and offer another budget when a human is present. Agents
|
|
@@ -197,9 +198,9 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
197
198
|
state.detail,
|
|
198
199
|
remedy.summary,
|
|
199
200
|
`Install steps (no typing): ${supportUrl}`,
|
|
200
|
-
`I will wait up to ${budgetLabel} for
|
|
201
|
+
`I will wait up to ${budgetLabel} for a Companion device to become ready.`,
|
|
201
202
|
].join('\n'),
|
|
202
|
-
'Finish setup on your
|
|
203
|
+
'Finish setup on your Companion device',
|
|
203
204
|
)
|
|
204
205
|
|
|
205
206
|
// Open the real support page so the user never has to type the URL. Decline
|
|
@@ -219,7 +220,9 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
219
220
|
const sleep =
|
|
220
221
|
deps.sleep ??
|
|
221
222
|
((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
222
|
-
const spinner = await deps.io.spinner?.(
|
|
223
|
+
const spinner = await deps.io.spinner?.(
|
|
224
|
+
`Waiting up to ${budgetLabel} for a Companion device…`,
|
|
225
|
+
)
|
|
223
226
|
let lastDevices: RoutableDevice[] = []
|
|
224
227
|
let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
225
228
|
|
|
@@ -227,14 +230,16 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
227
230
|
while (now() < deadline) {
|
|
228
231
|
try {
|
|
229
232
|
const response = await authed.client.listDevices()
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
+
const companionDevices = response.devices.filter(
|
|
234
|
+
(device) => device.platform === 'ios' || device.platform === 'android',
|
|
235
|
+
)
|
|
236
|
+
lastDevices = companionDevices
|
|
237
|
+
const ready = readyCompanionDevices(companionDevices)[0]
|
|
233
238
|
if (ready) {
|
|
234
239
|
spinner?.stop(`${ready.display_name} is ready to receive`)
|
|
235
240
|
return 'closed'
|
|
236
241
|
}
|
|
237
|
-
spinner?.message(deviceBridgeMessage(
|
|
242
|
+
spinner?.message(deviceBridgeMessage(companionDevices))
|
|
238
243
|
} catch (err) {
|
|
239
244
|
if (!(err instanceof NetworkError)) {
|
|
240
245
|
spinner?.error('Could not check companion readiness')
|
|
@@ -263,10 +268,12 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
263
268
|
false,
|
|
264
269
|
)
|
|
265
270
|
if (!keepWaiting) {
|
|
266
|
-
deps.io.out(
|
|
271
|
+
deps.io.out(
|
|
272
|
+
'Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.',
|
|
273
|
+
)
|
|
267
274
|
return 'pending'
|
|
268
275
|
}
|
|
269
|
-
spinner?.message(`Waiting another ${budgetLabel} for
|
|
276
|
+
spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`)
|
|
270
277
|
deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
271
278
|
}
|
|
272
279
|
}
|
|
@@ -284,10 +291,12 @@ function setupProofDraft(
|
|
|
284
291
|
: `This real notification completed setup verification for ${project}.`,
|
|
285
292
|
event: 'setup_verified',
|
|
286
293
|
kind: 'update',
|
|
287
|
-
platform:
|
|
294
|
+
platform: device.platform,
|
|
288
295
|
device: [device.device_id],
|
|
289
296
|
sound: 'none',
|
|
290
|
-
level
|
|
297
|
+
// Android has no caller-selected interruption level. Explicit null keeps an
|
|
298
|
+
// Apple preference in config from leaking into this Android-only proof.
|
|
299
|
+
level: device.platform === 'ios' ? 'passive' : null,
|
|
291
300
|
collapseKey: 'notifai-setup-verification',
|
|
292
301
|
})
|
|
293
302
|
}
|
|
@@ -331,8 +340,8 @@ async function submitSetupProof(
|
|
|
331
340
|
|
|
332
341
|
/**
|
|
333
342
|
* Send or resume one real setup probe, then wait for a Companion Receipt.
|
|
334
|
-
* Provider Acceptance is intentionally insufficient: it proves
|
|
335
|
-
* the
|
|
343
|
+
* Provider Acceptance is intentionally insufficient: it proves a push provider
|
|
344
|
+
* accepted the request, not that a companion process received it.
|
|
336
345
|
*/
|
|
337
346
|
async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
338
347
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
@@ -346,7 +355,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
346
355
|
reportError(deps, err)
|
|
347
356
|
return 'failed'
|
|
348
357
|
}
|
|
349
|
-
const candidates =
|
|
358
|
+
const candidates = readyCompanionDevices(devices)
|
|
350
359
|
const existing = readSetupProof(deps)
|
|
351
360
|
const target =
|
|
352
361
|
candidates.find(
|
|
@@ -354,7 +363,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
354
363
|
device.device_id === existing?.device_id && existing.project === config.project.value,
|
|
355
364
|
) ?? candidates[0]
|
|
356
365
|
if (!target) {
|
|
357
|
-
deps.io.err('Setup proof needs a receipt-capable iPhone.')
|
|
366
|
+
deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
|
|
358
367
|
return 'pending'
|
|
359
368
|
}
|
|
360
369
|
|
|
@@ -15,10 +15,12 @@ function managedSessionTitle(
|
|
|
15
15
|
active: ActiveHarnessSession | null,
|
|
16
16
|
): string | undefined {
|
|
17
17
|
if (active?.sessionLabel !== undefined) return active.sessionLabel
|
|
18
|
+
// The Orca worktree title names the workspace, not one harness: every
|
|
19
|
+
// harness session Orca starts inside that worktree inherits the same
|
|
20
|
+
// environment selector, so any active session with an identity to label
|
|
21
|
+
// may use it. An explicit label still wins without paying for the lookup.
|
|
18
22
|
if (
|
|
19
|
-
active?.
|
|
20
|
-
active.sessionId === undefined ||
|
|
21
|
-
active.sessionId !== deps.env['CLAUDE_CODE_SESSION_ID'] ||
|
|
23
|
+
active?.sessionId === undefined ||
|
|
22
24
|
flags.sessionLabel !== undefined ||
|
|
23
25
|
deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
|
|
24
26
|
) {
|
package/src/commands-send.ts
CHANGED
|
@@ -46,14 +46,13 @@ export async function sendCommand(
|
|
|
46
46
|
wait?: number
|
|
47
47
|
noWait?: boolean
|
|
48
48
|
replyTimeout?: number
|
|
49
|
-
noBlock?: boolean
|
|
50
49
|
idempotencyKey?: string
|
|
51
50
|
baseUrl?: string
|
|
52
51
|
},
|
|
53
52
|
): Promise<number> {
|
|
54
|
-
const
|
|
55
|
-
? flags.
|
|
56
|
-
: flags.
|
|
53
|
+
const hasChoice = Array.isArray(flags.choice)
|
|
54
|
+
? flags.choice.length > 0
|
|
55
|
+
: flags.choice !== undefined
|
|
57
56
|
if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
|
|
58
57
|
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
|
|
59
58
|
return EXIT.usage
|
|
@@ -75,9 +74,9 @@ export async function sendCommand(
|
|
|
75
74
|
}
|
|
76
75
|
if (
|
|
77
76
|
!flags.reply &&
|
|
78
|
-
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined ||
|
|
77
|
+
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined || hasChoice)
|
|
79
78
|
) {
|
|
80
|
-
deps.io.err('Use --reply with --reply-timeout, --reply-window,
|
|
79
|
+
deps.io.err('Use --reply with --reply-timeout, --reply-window, or --choice.')
|
|
81
80
|
return EXIT.usage
|
|
82
81
|
}
|
|
83
82
|
const replyTimeout = flags.replyTimeout ?? 900
|
|
@@ -91,13 +90,10 @@ export async function sendCommand(
|
|
|
91
90
|
// request id. So the user gets a real button, taps it, and nothing happens —
|
|
92
91
|
// worse than a banner that never asked, because it spends their attention
|
|
93
92
|
// 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)) {
|
|
93
|
+
if (flags.reply && replyTimeout === 0) {
|
|
98
94
|
deps.io.err(
|
|
99
95
|
'A question needs someone to hear the answer, so --reply cannot be combined ' +
|
|
100
|
-
'with --
|
|
96
|
+
'with --reply-timeout 0.\n' +
|
|
101
97
|
'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
|
|
102
98
|
'To announce finished work, drop --reply and its choices.',
|
|
103
99
|
)
|
|
@@ -165,13 +161,16 @@ export async function sendCommand(
|
|
|
165
161
|
for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
|
|
166
162
|
return EXIT.usage
|
|
167
163
|
}
|
|
164
|
+
for (const warning of validation.warnings) {
|
|
165
|
+
deps.io.err(`Heads up (${warning.path}): ${warning.message}`)
|
|
166
|
+
}
|
|
168
167
|
emitSendWarnings(deps, flags, config)
|
|
169
168
|
if (
|
|
170
169
|
!flags.reply &&
|
|
171
170
|
(flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
|
|
172
171
|
) {
|
|
173
172
|
deps.io.err(
|
|
174
|
-
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --
|
|
173
|
+
'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
174
|
)
|
|
176
175
|
}
|
|
177
176
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
@@ -220,23 +219,14 @@ export async function sendCommand(
|
|
|
220
219
|
receipt.overall === 'provider_accepted_all' &&
|
|
221
220
|
receipt.warnings.length === 0
|
|
222
221
|
if (!quietOrdinarySuccess) deps.io.out(formatReceipt(receipt))
|
|
223
|
-
}
|
|
222
|
+
}
|
|
224
223
|
|
|
225
224
|
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
226
225
|
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
227
226
|
if (flags.json) {
|
|
228
227
|
deps.io.out(
|
|
229
228
|
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
|
-
})
|
|
229
|
+
? JSON.stringify(unansweredReplyResultJson(receipt, false))
|
|
240
230
|
: JSON.stringify(receipt, null, 2),
|
|
241
231
|
)
|
|
242
232
|
}
|
|
@@ -253,7 +243,7 @@ export async function sendCommand(
|
|
|
253
243
|
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
254
244
|
if (flags.json) {
|
|
255
245
|
deps.io.out(
|
|
256
|
-
JSON.stringify(replyResultJson(result.response, result.degraded)),
|
|
246
|
+
JSON.stringify(replyResultJson(result.response, result.degraded, receipt)),
|
|
257
247
|
)
|
|
258
248
|
} else if (result.response.replies.length > 0) {
|
|
259
249
|
printReplies(deps, result.response.replies)
|
|
@@ -282,9 +272,9 @@ export async function sendCommand(
|
|
|
282
272
|
}
|
|
283
273
|
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
284
274
|
} catch (err) {
|
|
285
|
-
//
|
|
286
|
-
// Permanent poll errors (auth, closed window, not found) still
|
|
287
|
-
// always name the durable request and point at recovery.
|
|
275
|
+
// The send already succeeded durably; a wait fault must not read as "send
|
|
276
|
+
// failed". Permanent poll errors (auth, closed window, not found) still
|
|
277
|
+
// surface, but always name the durable request and point at recovery.
|
|
288
278
|
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
289
279
|
log(deps).error('cli.error', {
|
|
290
280
|
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
@@ -297,6 +287,9 @@ export async function sendCommand(
|
|
|
297
287
|
`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
298
288
|
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
|
|
299
289
|
)
|
|
290
|
+
// JSON callers still get their one object: the durable receipt, no
|
|
291
|
+
// replies, and `degraded: true` — "could not find out", not "no answer".
|
|
292
|
+
if (flags.json) deps.io.out(JSON.stringify(unansweredReplyResultJson(receipt, true)))
|
|
300
293
|
if (err instanceof ApiCallError) {
|
|
301
294
|
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
302
295
|
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
@@ -477,9 +470,19 @@ function isNonNegativeInteger(value: number): boolean {
|
|
|
477
470
|
}
|
|
478
471
|
|
|
479
472
|
|
|
480
|
-
|
|
473
|
+
/**
|
|
474
|
+
* The one JSON object `send --reply --json` prints, whatever happens after the
|
|
475
|
+
* durable submit. Embedding the receipt keeps stdout a single parseable line;
|
|
476
|
+
* `replies` (from a wait) omits it because there is no submission to report.
|
|
477
|
+
*/
|
|
478
|
+
function replyResultJson(
|
|
479
|
+
response: ListRepliesResponse,
|
|
480
|
+
degraded: boolean,
|
|
481
|
+
receipt?: SubmissionReceipt,
|
|
482
|
+
): object {
|
|
481
483
|
return {
|
|
482
484
|
type: 'reply_result',
|
|
485
|
+
...(receipt !== undefined ? { receipt } : {}),
|
|
483
486
|
request_id: response.request_id,
|
|
484
487
|
reply_expires_at: response.reply_expires_at,
|
|
485
488
|
replies: response.replies,
|
|
@@ -497,6 +500,25 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
|
|
|
497
500
|
}
|
|
498
501
|
}
|
|
499
502
|
|
|
503
|
+
/**
|
|
504
|
+
* The reply_result for a reply send that has no answer to report — a receipt
|
|
505
|
+
* failure, or a wait that faulted. Same shape, same single line; `degraded`
|
|
506
|
+
* distinguishes "the wait could not find out" from "delivery already failed".
|
|
507
|
+
*/
|
|
508
|
+
function unansweredReplyResultJson(receipt: SubmissionReceipt, degraded: boolean): object {
|
|
509
|
+
return {
|
|
510
|
+
type: 'reply_result',
|
|
511
|
+
receipt,
|
|
512
|
+
request_id: receipt.request_id,
|
|
513
|
+
replies: [],
|
|
514
|
+
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
515
|
+
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
516
|
+
agent_acknowledgement: null,
|
|
517
|
+
acknowledgement_command: null,
|
|
518
|
+
degraded,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
500
522
|
|
|
501
523
|
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
502
524
|
const logger = log(deps)
|