@raidiant/notifai 3.0.1 → 5.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 +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/commands.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
|
6
6
|
import {
|
|
7
7
|
AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
|
|
8
8
|
CAPABILITIES_V1,
|
|
9
|
+
PLATFORMS,
|
|
9
10
|
QUESTION_TEXT_MAX_LENGTH,
|
|
10
11
|
REPLY_MAX_QUESTIONS,
|
|
11
12
|
REPLY_MAX_WINDOW_SECONDS,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
type EvidenceSnapshot,
|
|
14
15
|
type AccountAccessResponse,
|
|
15
16
|
type ListRepliesResponse,
|
|
17
|
+
type NotificationDraftT,
|
|
16
18
|
type Platform,
|
|
17
19
|
type QuestionT,
|
|
18
20
|
type ReplyView,
|
|
@@ -31,7 +33,6 @@ import {
|
|
|
31
33
|
import {
|
|
32
34
|
BOOLEAN_CONFIG_KEYS,
|
|
33
35
|
CONFIG_KEYS,
|
|
34
|
-
LOG_LEVELS,
|
|
35
36
|
NUMERIC_CONFIG_KEYS,
|
|
36
37
|
configBounds,
|
|
37
38
|
configDefaultValue,
|
|
@@ -65,7 +66,7 @@ import {
|
|
|
65
66
|
} from './readiness.js'
|
|
66
67
|
import type { Tone } from './ui/theme.js'
|
|
67
68
|
import {
|
|
68
|
-
|
|
69
|
+
waiterCeilingSeconds,
|
|
69
70
|
clearAcknowledgementObligation,
|
|
70
71
|
handleSessionEnd,
|
|
71
72
|
handleStop,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
type EscalationDeliveryRoute,
|
|
82
83
|
type HookContext,
|
|
83
84
|
type HookHarness,
|
|
85
|
+
MIN_REPLY_WINDOW_SECONDS,
|
|
84
86
|
} from './hooks.js'
|
|
85
87
|
import {
|
|
86
88
|
LOG_EVENTS,
|
|
@@ -94,9 +96,11 @@ import {
|
|
|
94
96
|
renderRecord,
|
|
95
97
|
type LogQuery,
|
|
96
98
|
type Logger,
|
|
99
|
+
RECORD_LEVELS,
|
|
97
100
|
} from './logging.js'
|
|
98
101
|
import {
|
|
99
102
|
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
103
|
+
stopHandlerIsDetached,
|
|
100
104
|
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
101
105
|
HARNESSES,
|
|
102
106
|
applyPlan,
|
|
@@ -104,6 +108,8 @@ import {
|
|
|
104
108
|
buildHookConfig,
|
|
105
109
|
codexLayerDir,
|
|
106
110
|
codexLayerPaths,
|
|
111
|
+
codexCoexistenceNotes,
|
|
112
|
+
codexHomeNote,
|
|
107
113
|
codexRepresentationProblems,
|
|
108
114
|
codexTrustProblems,
|
|
109
115
|
codexProjectRoot,
|
|
@@ -153,8 +159,15 @@ import {
|
|
|
153
159
|
parseChoices,
|
|
154
160
|
receiptExitCode,
|
|
155
161
|
slugify,
|
|
162
|
+
validateMediaInputs,
|
|
163
|
+
type DraftInvocation,
|
|
156
164
|
type SendFlags,
|
|
157
165
|
} from './send.js'
|
|
166
|
+
import {
|
|
167
|
+
buildSourceContext,
|
|
168
|
+
inferInvocationContext,
|
|
169
|
+
projectSlugFrom as inferredProjectSlugFrom,
|
|
170
|
+
} from './invocation-context.js'
|
|
158
171
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
159
172
|
import { openUrl } from './platform.js'
|
|
160
173
|
|
|
@@ -304,6 +317,21 @@ function reportError(
|
|
|
304
317
|
})
|
|
305
318
|
if (err instanceof ApiCallError) {
|
|
306
319
|
deps.io.err(`${err.code}: ${err.message}`)
|
|
320
|
+
// The server's field-level details were already being logged above, but
|
|
321
|
+
// stderr is the one surface an agent's next turn actually reads — so the
|
|
322
|
+
// diagnosis has to be on it, the same way the hook path prints it.
|
|
323
|
+
const paths = rejectedPaths(err.details)
|
|
324
|
+
if (paths.length > 0) deps.io.err(`the server rejected: ${paths.join(', ')}`)
|
|
325
|
+
// A 422 on a request this CLI built is not a user error: the two sides
|
|
326
|
+
// disagree about the contract, in whichever direction. `doctor` says which.
|
|
327
|
+
// Codes that name a policy the account chose are the exception — the
|
|
328
|
+
// contract held, the body simply did not satisfy that account.
|
|
329
|
+
if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
|
|
330
|
+
deps.io.err(
|
|
331
|
+
'this build sent a field the server did not accept — the CLI and server ' +
|
|
332
|
+
'disagree about the contract; check with `notifai doctor`',
|
|
333
|
+
)
|
|
334
|
+
}
|
|
307
335
|
if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
|
|
308
336
|
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
309
337
|
return err.status >= 500 ? EXIT.network : EXIT.failed
|
|
@@ -544,6 +572,13 @@ export async function capabilitiesCommand(
|
|
|
544
572
|
deps: CommandDeps,
|
|
545
573
|
flags: { json?: boolean; platform?: Platform },
|
|
546
574
|
): Promise<number> {
|
|
575
|
+
// Locally, and with the same message `send` gives. Spending a round trip to
|
|
576
|
+
// have the server answer "Request validation failed" told the caller neither
|
|
577
|
+
// which flag was wrong nor what it accepts.
|
|
578
|
+
if (flags.platform !== undefined && !(PLATFORMS as readonly string[]).includes(flags.platform)) {
|
|
579
|
+
deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`)
|
|
580
|
+
return EXIT.usage
|
|
581
|
+
}
|
|
547
582
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
548
583
|
const credential = deps.store.load()
|
|
549
584
|
const baseUrl = resolvedBaseUrl(config, credential)
|
|
@@ -568,6 +603,36 @@ export async function capabilitiesCommand(
|
|
|
568
603
|
// send / status
|
|
569
604
|
// ---------------------------------------------------------------------------
|
|
570
605
|
|
|
606
|
+
function resolveDraftInvocation(
|
|
607
|
+
deps: CommandDeps,
|
|
608
|
+
flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
|
|
609
|
+
active: ActiveHarnessSession | null,
|
|
610
|
+
): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
|
|
611
|
+
const inferred = inferInvocationContext(deps.cwd)
|
|
612
|
+
const source = buildSourceContext({
|
|
613
|
+
env: deps.env,
|
|
614
|
+
invocation: inferred,
|
|
615
|
+
...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
|
|
616
|
+
...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
|
|
617
|
+
...(active === null
|
|
618
|
+
? {}
|
|
619
|
+
: {
|
|
620
|
+
activeHarness: {
|
|
621
|
+
harness: active.harness,
|
|
622
|
+
...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
|
|
623
|
+
},
|
|
624
|
+
}),
|
|
625
|
+
})
|
|
626
|
+
if (!source.ok) return source
|
|
627
|
+
return {
|
|
628
|
+
ok: true,
|
|
629
|
+
invocation: {
|
|
630
|
+
inferredProject: inferred.project,
|
|
631
|
+
...(source.source === undefined ? {} : { source: source.source }),
|
|
632
|
+
},
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
571
636
|
export async function sendCommand(
|
|
572
637
|
deps: CommandDeps,
|
|
573
638
|
flags: SendFlags & {
|
|
@@ -583,8 +648,23 @@ export async function sendCommand(
|
|
|
583
648
|
const hasReplyChoice = Array.isArray(flags.replyChoice)
|
|
584
649
|
? flags.replyChoice.length > 0
|
|
585
650
|
: flags.replyChoice !== undefined
|
|
586
|
-
if (flags.reply && flags.kind
|
|
587
|
-
deps.io.err(
|
|
651
|
+
if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
|
|
652
|
+
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
|
|
653
|
+
return EXIT.usage
|
|
654
|
+
}
|
|
655
|
+
// Kind now decides the sound a notification arrives with, so it is asked for
|
|
656
|
+
// at the boundary rather than defaulted silently: an unlabelled send would
|
|
657
|
+
// reach the user as ordinary news whatever actually happened.
|
|
658
|
+
if (!flags.reply && flags.kind === undefined) {
|
|
659
|
+
deps.io.err(
|
|
660
|
+
'--kind is required: say what this notification is. ' +
|
|
661
|
+
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
|
|
662
|
+
'--reply makes it a question without --kind.',
|
|
663
|
+
)
|
|
664
|
+
return EXIT.usage
|
|
665
|
+
}
|
|
666
|
+
if (!flags.reply && flags.kind === 'question') {
|
|
667
|
+
deps.io.err('--kind question requires --reply so the question can be answered.')
|
|
588
668
|
return EXIT.usage
|
|
589
669
|
}
|
|
590
670
|
if (
|
|
@@ -621,10 +701,17 @@ export async function sendCommand(
|
|
|
621
701
|
flags.reply &&
|
|
622
702
|
flags.replyWindow !== undefined &&
|
|
623
703
|
(!Number.isInteger(flags.replyWindow) ||
|
|
624
|
-
flags.replyWindow <
|
|
704
|
+
flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
|
|
625
705
|
flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
|
|
626
706
|
) {
|
|
627
|
-
deps.io.err(
|
|
707
|
+
deps.io.err(
|
|
708
|
+
`--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`,
|
|
709
|
+
)
|
|
710
|
+
return EXIT.usage
|
|
711
|
+
}
|
|
712
|
+
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
713
|
+
if (mediaInputError !== null) {
|
|
714
|
+
deps.io.err(mediaInputError)
|
|
628
715
|
return EXIT.usage
|
|
629
716
|
}
|
|
630
717
|
const config = loadLoggedConfig(deps, {
|
|
@@ -632,17 +719,32 @@ export async function sendCommand(
|
|
|
632
719
|
env: deps.env,
|
|
633
720
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
|
|
634
721
|
})
|
|
722
|
+
const source = resolveDraftInvocation(
|
|
723
|
+
deps,
|
|
724
|
+
flags,
|
|
725
|
+
activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
|
|
726
|
+
)
|
|
727
|
+
if (!source.ok) {
|
|
728
|
+
deps.io.err(source.error)
|
|
729
|
+
return EXIT.usage
|
|
730
|
+
}
|
|
635
731
|
const authed = authedClient(deps, config)
|
|
636
732
|
if (!authed) return EXIT.auth
|
|
637
|
-
|
|
638
|
-
|
|
733
|
+
const mediaIds: string[] = []
|
|
734
|
+
for (const image of flags.image ?? []) {
|
|
735
|
+
if (image.startsWith('med_')) {
|
|
736
|
+
mediaIds.push(image)
|
|
737
|
+
continue
|
|
738
|
+
}
|
|
739
|
+
const uploaded = await uploadImage(deps, authed.client, image)
|
|
639
740
|
if (!uploaded.ok) {
|
|
640
|
-
deps.io.err(uploaded.error)
|
|
741
|
+
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
641
742
|
return uploaded.exit
|
|
642
743
|
}
|
|
643
|
-
|
|
744
|
+
mediaIds.push(uploaded.mediaId)
|
|
644
745
|
}
|
|
645
|
-
|
|
746
|
+
flags = { ...flags, image: mediaIds }
|
|
747
|
+
const build = buildDraft(config, flags, source.invocation)
|
|
646
748
|
if (!build.ok) {
|
|
647
749
|
deps.io.err(build.error)
|
|
648
750
|
return EXIT.usage
|
|
@@ -719,6 +821,7 @@ export async function sendCommand(
|
|
|
719
821
|
request_id: receipt.request_id,
|
|
720
822
|
replies: [],
|
|
721
823
|
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
824
|
+
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
722
825
|
agent_acknowledgement: null,
|
|
723
826
|
acknowledgement_command: null,
|
|
724
827
|
degraded: false,
|
|
@@ -805,15 +908,9 @@ function emitSendWarnings(
|
|
|
805
908
|
`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
|
|
806
909
|
)
|
|
807
910
|
}
|
|
808
|
-
if (
|
|
809
|
-
deps.io.err(
|
|
810
|
-
'Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.',
|
|
811
|
-
)
|
|
812
|
-
}
|
|
813
|
-
const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
|
|
814
|
-
if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
|
|
911
|
+
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
815
912
|
deps.io.err(
|
|
816
|
-
|
|
913
|
+
'Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.',
|
|
817
914
|
)
|
|
818
915
|
}
|
|
819
916
|
if (
|
|
@@ -832,12 +929,6 @@ function emitSendWarnings(
|
|
|
832
929
|
}
|
|
833
930
|
}
|
|
834
931
|
|
|
835
|
-
function looksLikeMarkdown(value: string): boolean {
|
|
836
|
-
return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(
|
|
837
|
-
value,
|
|
838
|
-
)
|
|
839
|
-
}
|
|
840
|
-
|
|
841
932
|
export async function repliesCommand(
|
|
842
933
|
deps: CommandDeps,
|
|
843
934
|
requestedId: string | undefined,
|
|
@@ -872,7 +963,14 @@ export async function repliesCommand(
|
|
|
872
963
|
.map((entry) => entry.request_id)
|
|
873
964
|
.filter((id): id is string => id !== undefined)
|
|
874
965
|
if (requestIds.length === 0) {
|
|
875
|
-
|
|
966
|
+
// `--json` is a promise about stdout, and an empty result is still a
|
|
967
|
+
// result: a caller that asked for machine-readable output must not have
|
|
968
|
+
// to parse an English sentence to learn there was nothing pending.
|
|
969
|
+
if (flags.json === true) {
|
|
970
|
+
deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2))
|
|
971
|
+
} else {
|
|
972
|
+
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
973
|
+
}
|
|
876
974
|
return EXIT.noReply
|
|
877
975
|
}
|
|
878
976
|
}
|
|
@@ -1008,6 +1106,7 @@ export async function waitForReply(
|
|
|
1008
1106
|
request_id: requestId,
|
|
1009
1107
|
reply_expires_at: null,
|
|
1010
1108
|
agent_acknowledgement_required: false,
|
|
1109
|
+
agent_acknowledgement_text_required: false,
|
|
1011
1110
|
agent_acknowledgement: null,
|
|
1012
1111
|
replies: [],
|
|
1013
1112
|
} satisfies ListRepliesResponse),
|
|
@@ -1050,14 +1149,21 @@ function isNonNegativeInteger(value: number): boolean {
|
|
|
1050
1149
|
return Number.isInteger(value) && value >= 0
|
|
1051
1150
|
}
|
|
1052
1151
|
|
|
1152
|
+
function acknowledgeInvocation(requestId: string, textRequired: boolean): string {
|
|
1153
|
+
return textRequired
|
|
1154
|
+
? `notifai acknowledge ${requestId} --text <text>`
|
|
1155
|
+
: `notifai acknowledge ${requestId}`
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1053
1158
|
function acknowledgementCommand(
|
|
1054
1159
|
requestId: string,
|
|
1055
1160
|
required: boolean,
|
|
1161
|
+
textRequired: boolean,
|
|
1056
1162
|
acknowledgement: ListRepliesResponse['agent_acknowledgement'],
|
|
1057
1163
|
hasReply = true,
|
|
1058
1164
|
): string | null {
|
|
1059
1165
|
return required && acknowledgement === null && hasReply
|
|
1060
|
-
?
|
|
1166
|
+
? acknowledgeInvocation(requestId, textRequired)
|
|
1061
1167
|
: null
|
|
1062
1168
|
}
|
|
1063
1169
|
|
|
@@ -1069,9 +1175,11 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
|
|
|
1069
1175
|
replies: response.replies,
|
|
1070
1176
|
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
1071
1177
|
agent_acknowledgement: response.agent_acknowledgement,
|
|
1178
|
+
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
1072
1179
|
acknowledgement_command: acknowledgementCommand(
|
|
1073
1180
|
response.request_id,
|
|
1074
1181
|
response.agent_acknowledgement_required,
|
|
1182
|
+
response.agent_acknowledgement_text_required,
|
|
1075
1183
|
response.agent_acknowledgement,
|
|
1076
1184
|
response.replies.length > 0,
|
|
1077
1185
|
),
|
|
@@ -1084,16 +1192,22 @@ function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResp
|
|
|
1084
1192
|
deps.io.out('Agent Acknowledgement: not required for this request.')
|
|
1085
1193
|
return
|
|
1086
1194
|
}
|
|
1195
|
+
const textRequired = response.agent_acknowledgement_text_required
|
|
1087
1196
|
if (response.agent_acknowledgement !== null) {
|
|
1197
|
+
const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`
|
|
1088
1198
|
deps.io.out(
|
|
1089
|
-
|
|
1199
|
+
response.agent_acknowledgement.text.length > 0
|
|
1200
|
+
? `${recorded}: ${response.agent_acknowledgement.text}`
|
|
1201
|
+
: `${recorded}.`,
|
|
1090
1202
|
)
|
|
1091
1203
|
return
|
|
1092
1204
|
}
|
|
1093
1205
|
if (response.replies.length > 0) {
|
|
1094
1206
|
deps.io.out('Agent Acknowledgement required.')
|
|
1095
1207
|
deps.io.out(
|
|
1096
|
-
|
|
1208
|
+
textRequired
|
|
1209
|
+
? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
|
|
1210
|
+
: `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`,
|
|
1097
1211
|
)
|
|
1098
1212
|
return
|
|
1099
1213
|
}
|
|
@@ -1233,7 +1347,8 @@ const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
|
|
|
1233
1347
|
|
|
1234
1348
|
type UploadResult =
|
|
1235
1349
|
| { ok: true; mediaId: string }
|
|
1236
|
-
|
|
1350
|
+
/** `error: null` means `reportError` already said it; do not print it twice. */
|
|
1351
|
+
| { ok: false; error: string | null; exit: number }
|
|
1237
1352
|
|
|
1238
1353
|
/** `--image` accepts a media id, a local file path, or an http(s) URL. */
|
|
1239
1354
|
async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
|
|
@@ -1268,7 +1383,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
|
|
|
1268
1383
|
await client.uploadMedia(grant, bytes)
|
|
1269
1384
|
return { ok: true, mediaId: grant.media_id }
|
|
1270
1385
|
} catch (err) {
|
|
1271
|
-
|
|
1386
|
+
// Every other API failure in this file goes through `reportError`, which
|
|
1387
|
+
// maps a revoked credential to the auth code, a server fault to the network
|
|
1388
|
+
// one and everything else to plain failure, and records it in the local
|
|
1389
|
+
// log with whatever next step the server named. This used to answer
|
|
1390
|
+
// `network` to all of them, so an image too large for the account exited
|
|
1391
|
+
// the same way an unreachable server did, and nothing about it was logged.
|
|
1392
|
+
return { ok: false, error: null, exit: reportError(deps, err) }
|
|
1272
1393
|
}
|
|
1273
1394
|
}
|
|
1274
1395
|
|
|
@@ -1311,7 +1432,11 @@ export async function hookRunCommand(
|
|
|
1311
1432
|
// would grant slow setup a second budget and let the harness kill us before
|
|
1312
1433
|
// an accepted answer is journaled or written to stdout.
|
|
1313
1434
|
const now = deps.now ?? Date.now
|
|
1314
|
-
|
|
1435
|
+
// The waiter may spend a long wall clock exactly when no turn is held open
|
|
1436
|
+
// for it, which is the same condition the installer used to declare
|
|
1437
|
+
// `async: true`. One predicate decides it for both.
|
|
1438
|
+
const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform)
|
|
1439
|
+
const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000
|
|
1315
1440
|
|
|
1316
1441
|
const logger = log(deps)
|
|
1317
1442
|
logger.bind({ cmd: `hook ${event}` })
|
|
@@ -1504,20 +1629,7 @@ export async function hookRunCommand(
|
|
|
1504
1629
|
...outcome.log,
|
|
1505
1630
|
})
|
|
1506
1631
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1507
|
-
if (outcome.stdout !== undefined)
|
|
1508
|
-
let stdout = outcome.stdout
|
|
1509
|
-
if (
|
|
1510
|
-
harness !== undefined &&
|
|
1511
|
-
event === 'stop' &&
|
|
1512
|
-
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
|
|
1513
|
-
) {
|
|
1514
|
-
const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
|
|
1515
|
-
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
1516
|
-
stdout = JSON.stringify({ followup_message: decision.reason })
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
deps.io.out(stdout)
|
|
1520
|
-
}
|
|
1632
|
+
if (outcome.stdout !== undefined) deps.io.out(outcome.stdout)
|
|
1521
1633
|
return EXIT.ok
|
|
1522
1634
|
} catch (err) {
|
|
1523
1635
|
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
@@ -1637,14 +1749,20 @@ function rejectedPaths(details: unknown): string[] {
|
|
|
1637
1749
|
}
|
|
1638
1750
|
|
|
1639
1751
|
export interface AskFlags {
|
|
1752
|
+
/** Emit the registration and its turn obligation as one JSON object. */
|
|
1753
|
+
json?: boolean
|
|
1640
1754
|
choice?: string[]
|
|
1641
1755
|
/** The single question is multi-select: several answers may be chosen. */
|
|
1642
1756
|
multi?: boolean
|
|
1643
|
-
/**
|
|
1644
|
-
|
|
1757
|
+
/** Optional Markdown context appended after the question block. */
|
|
1758
|
+
body?: string
|
|
1645
1759
|
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
1646
1760
|
form?: string
|
|
1647
|
-
|
|
1761
|
+
image?: string[]
|
|
1762
|
+
imageAlt?: string[]
|
|
1763
|
+
project?: string
|
|
1764
|
+
sessionId?: string
|
|
1765
|
+
sessionLabel?: string
|
|
1648
1766
|
}
|
|
1649
1767
|
|
|
1650
1768
|
/** The `--form` document: what an agent writes to ask several things at once. */
|
|
@@ -1654,15 +1772,20 @@ interface AskFormQuestion {
|
|
|
1654
1772
|
multi?: boolean
|
|
1655
1773
|
}
|
|
1656
1774
|
|
|
1775
|
+
export interface BuiltQuestions {
|
|
1776
|
+
questions: QuestionT[]
|
|
1777
|
+
/** Canonical Markdown body: question block first, optional context second. */
|
|
1778
|
+
body: string
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1657
1781
|
/**
|
|
1658
|
-
* Turn ask input into
|
|
1659
|
-
* validated
|
|
1660
|
-
* where a rejection becomes a stderr note the agent never reads.
|
|
1782
|
+
* Turn ask input into questions plus their canonical body. Everything is
|
|
1783
|
+
* validated at registration because a later hook failure is easy to miss.
|
|
1661
1784
|
*/
|
|
1662
1785
|
export function buildQuestions(
|
|
1663
1786
|
flags: AskFlags,
|
|
1664
1787
|
question: string | undefined,
|
|
1665
|
-
): { ok: true; questions: QuestionT[];
|
|
1788
|
+
): { ok: true; questions: QuestionT[]; body: string } | { ok: false; error: string } {
|
|
1666
1789
|
if (flags.form !== undefined) {
|
|
1667
1790
|
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
1668
1791
|
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
|
|
@@ -1671,21 +1794,41 @@ export function buildQuestions(
|
|
|
1671
1794
|
try {
|
|
1672
1795
|
parsed = JSON.parse(flags.form)
|
|
1673
1796
|
} catch {
|
|
1674
|
-
return {
|
|
1797
|
+
return {
|
|
1798
|
+
ok: false,
|
|
1799
|
+
error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
1803
|
+
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
|
|
1804
|
+
}
|
|
1805
|
+
const record = parsed as Record<string, unknown>
|
|
1806
|
+
const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
|
|
1807
|
+
if (unknownKeys.length > 0) {
|
|
1808
|
+
return {
|
|
1809
|
+
ok: false,
|
|
1810
|
+
error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
if (!Array.isArray(record['questions'])) {
|
|
1814
|
+
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
|
|
1675
1815
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1816
|
+
const formQuestions = record['questions']
|
|
1817
|
+
if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
|
|
1818
|
+
return {
|
|
1819
|
+
ok: false,
|
|
1820
|
+
error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
|
|
1821
|
+
}
|
|
1678
1822
|
}
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
|
|
1823
|
+
if (record['body'] !== undefined && typeof record['body'] !== 'string') {
|
|
1824
|
+
return { ok: false, error: '"body" must be a Markdown string.' }
|
|
1682
1825
|
}
|
|
1683
|
-
if (
|
|
1684
|
-
return { ok: false, error: '"
|
|
1826
|
+
if (flags.body !== undefined && record['body'] !== undefined) {
|
|
1827
|
+
return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' }
|
|
1685
1828
|
}
|
|
1686
1829
|
const questions: QuestionT[] = []
|
|
1687
1830
|
const usedIds = new Set<string>()
|
|
1688
|
-
for (const [index, entry] of
|
|
1831
|
+
for (const [index, entry] of formQuestions.entries()) {
|
|
1689
1832
|
if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
|
|
1690
1833
|
return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
|
|
1691
1834
|
}
|
|
@@ -1694,7 +1837,16 @@ export function buildQuestions(
|
|
|
1694
1837
|
if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
|
|
1695
1838
|
questions.push(built.question)
|
|
1696
1839
|
}
|
|
1697
|
-
|
|
1840
|
+
const context = flags.body ?? (record['body'] as string | undefined)
|
|
1841
|
+
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
|
|
1842
|
+
return {
|
|
1843
|
+
ok: true,
|
|
1844
|
+
questions,
|
|
1845
|
+
body:
|
|
1846
|
+
context !== undefined && context.trim() !== ''
|
|
1847
|
+
? `${questionBlock}\n\n${context}`
|
|
1848
|
+
: questionBlock,
|
|
1849
|
+
}
|
|
1698
1850
|
}
|
|
1699
1851
|
|
|
1700
1852
|
if (question === undefined || question.trim() === '') {
|
|
@@ -1702,10 +1854,14 @@ export function buildQuestions(
|
|
|
1702
1854
|
}
|
|
1703
1855
|
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
|
|
1704
1856
|
if ('error' in built) return { ok: false, error: built.error }
|
|
1857
|
+
const context = flags.body
|
|
1705
1858
|
return {
|
|
1706
1859
|
ok: true,
|
|
1707
1860
|
questions: [built.question],
|
|
1708
|
-
|
|
1861
|
+
body:
|
|
1862
|
+
context !== undefined && context.trim() !== ''
|
|
1863
|
+
? `${built.question.text}\n\n${context}`
|
|
1864
|
+
: built.question.text,
|
|
1709
1865
|
}
|
|
1710
1866
|
}
|
|
1711
1867
|
|
|
@@ -1722,7 +1878,7 @@ function buildOneQuestion(
|
|
|
1722
1878
|
return {
|
|
1723
1879
|
error:
|
|
1724
1880
|
`a question must be readable where it is answered: keep it within ` +
|
|
1725
|
-
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in
|
|
1881
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
|
|
1726
1882
|
}
|
|
1727
1883
|
}
|
|
1728
1884
|
const choices = parseChoices(choiceLabels)
|
|
@@ -1743,12 +1899,186 @@ function buildOneQuestion(
|
|
|
1743
1899
|
}
|
|
1744
1900
|
}
|
|
1745
1901
|
|
|
1902
|
+
function buildAskDraft(
|
|
1903
|
+
config: CliConfig,
|
|
1904
|
+
built: BuiltQuestions,
|
|
1905
|
+
flags: AskFlags,
|
|
1906
|
+
invocation: DraftInvocation,
|
|
1907
|
+
mediaIds: string[],
|
|
1908
|
+
): { ok: true; draft: NotificationDraftT } | { ok: false; error: string } {
|
|
1909
|
+
const result = buildDraft(
|
|
1910
|
+
config,
|
|
1911
|
+
{
|
|
1912
|
+
title: built.questions[0]!.text,
|
|
1913
|
+
body: built.body,
|
|
1914
|
+
...(flags.project !== undefined ? { project: flags.project } : {}),
|
|
1915
|
+
...(mediaIds.length > 0 ? { image: mediaIds } : {}),
|
|
1916
|
+
...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
|
|
1917
|
+
reply: true,
|
|
1918
|
+
questions: built.questions,
|
|
1919
|
+
},
|
|
1920
|
+
invocation,
|
|
1921
|
+
)
|
|
1922
|
+
if (!result.ok) return result
|
|
1923
|
+
const capabilities = CAPABILITIES_V1.describe(result.platform)
|
|
1924
|
+
if (capabilities === null) return { ok: false, error: 'No iOS capability contract is available.' }
|
|
1925
|
+
const validation = validateDraft(result.draft, capabilities)
|
|
1926
|
+
if (!validation.ok) {
|
|
1927
|
+
return {
|
|
1928
|
+
ok: false,
|
|
1929
|
+
error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return { ok: true, draft: result.draft }
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function recordRegisteredQuestion(
|
|
1936
|
+
deps: CommandDeps,
|
|
1937
|
+
sessionId: string,
|
|
1938
|
+
built: BuiltQuestions,
|
|
1939
|
+
draft: NotificationDraftT,
|
|
1940
|
+
json = false,
|
|
1941
|
+
): number {
|
|
1942
|
+
try {
|
|
1943
|
+
registerQuestion(
|
|
1944
|
+
sessionId,
|
|
1945
|
+
deps.env,
|
|
1946
|
+
{
|
|
1947
|
+
question: built.questions[0]!.text,
|
|
1948
|
+
questions: built.questions,
|
|
1949
|
+
body: draft.presentation.body,
|
|
1950
|
+
...(draft.project !== undefined ? { project: draft.project } : {}),
|
|
1951
|
+
...(draft.source !== undefined ? { source: draft.source } : {}),
|
|
1952
|
+
...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
|
|
1953
|
+
},
|
|
1954
|
+
(deps.now ?? Date.now)(),
|
|
1955
|
+
)
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
1958
|
+
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1959
|
+
return EXIT.failed
|
|
1960
|
+
}
|
|
1961
|
+
log(deps).info('ask.registered', {
|
|
1962
|
+
ok: true,
|
|
1963
|
+
session: sessionId,
|
|
1964
|
+
questions: built.questions.length,
|
|
1965
|
+
text: built.questions[0]!.text,
|
|
1966
|
+
choices: built.questions[0]!.choices?.length ?? 0,
|
|
1967
|
+
media: draft.presentation.media?.length ?? 0,
|
|
1968
|
+
})
|
|
1969
|
+
// The block below is the densest guidance this CLI prints, and until now it
|
|
1970
|
+
// was prose only: an agent could not read back the choice ids it must branch
|
|
1971
|
+
// on without asking the server for them. The JSON form carries the same
|
|
1972
|
+
// obligation as data.
|
|
1973
|
+
if (json) {
|
|
1974
|
+
deps.io.out(
|
|
1975
|
+
JSON.stringify(
|
|
1976
|
+
{
|
|
1977
|
+
registered: true,
|
|
1978
|
+
questions: built.questions.map((entry) => ({
|
|
1979
|
+
id: entry.id,
|
|
1980
|
+
text: entry.text,
|
|
1981
|
+
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
1982
|
+
...(entry.multi === true ? { multi: true } : {}),
|
|
1983
|
+
})),
|
|
1984
|
+
next: {
|
|
1985
|
+
end_turn: true,
|
|
1986
|
+
in_this_turn:
|
|
1987
|
+
'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
|
|
1988
|
+
route_neutral:
|
|
1989
|
+
'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
1990
|
+
on_answer:
|
|
1991
|
+
'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
1992
|
+
},
|
|
1993
|
+
},
|
|
1994
|
+
null,
|
|
1995
|
+
2,
|
|
1996
|
+
),
|
|
1997
|
+
)
|
|
1998
|
+
return EXIT.ok
|
|
1999
|
+
}
|
|
2000
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
2001
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
2002
|
+
if (entry.choices !== undefined) {
|
|
2003
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
|
|
2004
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
|
|
2005
|
+
} else if (built.questions.length > 1) {
|
|
2006
|
+
deps.io.out(`${prefix}${entry.text} — free text`)
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
deps.io.out(
|
|
2010
|
+
built.questions.length > 1
|
|
2011
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
|
|
2012
|
+
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
2013
|
+
)
|
|
2014
|
+
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
|
|
2015
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
2016
|
+
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
|
|
2017
|
+
if (entry.choices !== undefined) {
|
|
2018
|
+
for (const choice of entry.choices) {
|
|
2019
|
+
deps.io.out(
|
|
2020
|
+
`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
|
|
2021
|
+
)
|
|
2022
|
+
}
|
|
2023
|
+
deps.io.out(
|
|
2024
|
+
`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
|
|
2025
|
+
)
|
|
2026
|
+
} else {
|
|
2027
|
+
deps.io.out(
|
|
2028
|
+
`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
|
|
2029
|
+
)
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
deps.io.out(
|
|
2033
|
+
'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
|
|
2034
|
+
)
|
|
2035
|
+
deps.io.out(
|
|
2036
|
+
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
2037
|
+
)
|
|
2038
|
+
return EXIT.ok
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
async function uploadAskMedia(
|
|
2042
|
+
deps: CommandDeps,
|
|
2043
|
+
config: CliConfig,
|
|
2044
|
+
sessionId: string,
|
|
2045
|
+
built: BuiltQuestions,
|
|
2046
|
+
flags: AskFlags,
|
|
2047
|
+
invocation: DraftInvocation,
|
|
2048
|
+
): Promise<number> {
|
|
2049
|
+
const authed = authedClient(deps, config)
|
|
2050
|
+
if (!authed) return EXIT.auth
|
|
2051
|
+
const mediaIds: string[] = []
|
|
2052
|
+
for (const image of flags.image ?? []) {
|
|
2053
|
+
if (image.startsWith('med_')) {
|
|
2054
|
+
mediaIds.push(image)
|
|
2055
|
+
continue
|
|
2056
|
+
}
|
|
2057
|
+
const uploaded = await uploadImage(deps, authed.client, image)
|
|
2058
|
+
if (!uploaded.ok) {
|
|
2059
|
+
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
2060
|
+
return uploaded.exit
|
|
2061
|
+
}
|
|
2062
|
+
mediaIds.push(uploaded.mediaId)
|
|
2063
|
+
}
|
|
2064
|
+
const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
|
|
2065
|
+
if (!ready.ok) {
|
|
2066
|
+
deps.io.err(ready.error)
|
|
2067
|
+
return EXIT.usage
|
|
2068
|
+
}
|
|
2069
|
+
return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1746
2072
|
/**
|
|
1747
2073
|
* Registers a question for turn-end routing. Returns immediately so the agent
|
|
1748
2074
|
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
1749
2075
|
* for `ask_grace_seconds` before it reaches any device.
|
|
1750
2076
|
*/
|
|
1751
|
-
export function askCommand(
|
|
2077
|
+
export function askCommand(
|
|
2078
|
+
deps: CommandDeps,
|
|
2079
|
+
question: string | undefined,
|
|
2080
|
+
flags: AskFlags,
|
|
2081
|
+
): number | Promise<number> {
|
|
1752
2082
|
// Validate before route discovery. A malformed question belongs to the
|
|
1753
2083
|
// caller and should not be hidden behind whichever harness setup issue
|
|
1754
2084
|
// happens to exist on this machine.
|
|
@@ -1757,6 +2087,11 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1757
2087
|
deps.io.err(built.error)
|
|
1758
2088
|
return EXIT.usage
|
|
1759
2089
|
}
|
|
2090
|
+
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
2091
|
+
if (mediaInputError !== null) {
|
|
2092
|
+
deps.io.err(mediaInputError)
|
|
2093
|
+
return EXIT.usage
|
|
2094
|
+
}
|
|
1760
2095
|
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1761
2096
|
if (!routingConfig.ask_notifications.value) {
|
|
1762
2097
|
deps.io.err(
|
|
@@ -1816,12 +2151,6 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1816
2151
|
}
|
|
1817
2152
|
return EXIT.usage
|
|
1818
2153
|
}
|
|
1819
|
-
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1820
|
-
deps.io.err(
|
|
1821
|
-
`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
|
|
1822
|
-
)
|
|
1823
|
-
return EXIT.usage
|
|
1824
|
-
}
|
|
1825
2154
|
const routeProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
1826
2155
|
if (routeProblems.length > 0) {
|
|
1827
2156
|
for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
|
|
@@ -1845,71 +2174,37 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1845
2174
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1846
2175
|
return EXIT.usage
|
|
1847
2176
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
},
|
|
1857
|
-
(deps.now ?? Date.now)(),
|
|
2177
|
+
const source = resolveDraftInvocation(deps, flags, active)
|
|
2178
|
+
if (!source.ok) {
|
|
2179
|
+
deps.io.err(source.error)
|
|
2180
|
+
return EXIT.usage
|
|
2181
|
+
}
|
|
2182
|
+
if (source.invocation.source?.session_id !== sessionId) {
|
|
2183
|
+
deps.io.err(
|
|
2184
|
+
`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`,
|
|
1858
2185
|
)
|
|
1859
|
-
|
|
1860
|
-
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
1861
|
-
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1862
|
-
return EXIT.failed
|
|
2186
|
+
return EXIT.usage
|
|
1863
2187
|
}
|
|
1864
|
-
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
choices: built.questions[0]!.choices?.length ?? 0,
|
|
1873
|
-
})
|
|
1874
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1875
|
-
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1876
|
-
if (entry.choices !== undefined) {
|
|
1877
|
-
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
|
|
1878
|
-
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
|
|
1879
|
-
} else if (built.questions.length > 1) {
|
|
1880
|
-
deps.io.out(`${prefix}${entry.text} — free text`)
|
|
1881
|
-
}
|
|
2188
|
+
|
|
2189
|
+
// Placeholders let every body, source, project, media, and payload limit fail
|
|
2190
|
+
// before an upload starts. The real ids replace them only after this passes.
|
|
2191
|
+
const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
|
|
2192
|
+
const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
|
|
2193
|
+
if (!preflight.ok) {
|
|
2194
|
+
deps.io.err(preflight.error)
|
|
2195
|
+
return EXIT.usage
|
|
1882
2196
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
for (const choice of entry.choices) {
|
|
1893
|
-
deps.io.out(
|
|
1894
|
-
`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
|
|
1895
|
-
)
|
|
1896
|
-
}
|
|
1897
|
-
deps.io.out(
|
|
1898
|
-
`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
|
|
1899
|
-
)
|
|
1900
|
-
} else {
|
|
1901
|
-
deps.io.out(
|
|
1902
|
-
`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
|
|
1903
|
-
)
|
|
1904
|
-
}
|
|
2197
|
+
if ((flags.image?.length ?? 0) > 0) {
|
|
2198
|
+
return uploadAskMedia(
|
|
2199
|
+
deps,
|
|
2200
|
+
routingConfig,
|
|
2201
|
+
sessionId,
|
|
2202
|
+
built,
|
|
2203
|
+
flags,
|
|
2204
|
+
source.invocation,
|
|
2205
|
+
)
|
|
1905
2206
|
}
|
|
1906
|
-
deps.
|
|
1907
|
-
'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
|
|
1908
|
-
)
|
|
1909
|
-
deps.io.out(
|
|
1910
|
-
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
1911
|
-
)
|
|
1912
|
-
return EXIT.ok
|
|
2207
|
+
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true)
|
|
1913
2208
|
}
|
|
1914
2209
|
|
|
1915
2210
|
/**
|
|
@@ -2187,20 +2482,27 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
2187
2482
|
return `${advice.join('. ')}.`
|
|
2188
2483
|
}
|
|
2189
2484
|
|
|
2190
|
-
/**
|
|
2485
|
+
/**
|
|
2486
|
+
* Record the one Agent Acknowledgement associated with a replied-to request.
|
|
2487
|
+
*
|
|
2488
|
+
* `--text` is optional here because the acknowledgement is not: an account may
|
|
2489
|
+
* turn the agent's written reply off, and the receipt must still be recorded so
|
|
2490
|
+
* the user sees that an agent read the answer. The service holds the account's
|
|
2491
|
+
* snapshot, so it — not this process — decides whether text was owed.
|
|
2492
|
+
*/
|
|
2191
2493
|
export async function acknowledgeCommand(
|
|
2192
2494
|
deps: CommandDeps,
|
|
2193
2495
|
requestId: string,
|
|
2194
2496
|
flags: { text?: string; json?: boolean },
|
|
2195
2497
|
): Promise<number> {
|
|
2196
2498
|
const text = flags.text?.trim() ?? ''
|
|
2197
|
-
if (text.length === 0) {
|
|
2198
|
-
deps.io.err('--text
|
|
2499
|
+
if (flags.text !== undefined && text.length === 0) {
|
|
2500
|
+
deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.')
|
|
2199
2501
|
return EXIT.usage
|
|
2200
2502
|
}
|
|
2201
2503
|
if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
|
|
2202
2504
|
deps.io.err(
|
|
2203
|
-
`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`,
|
|
2505
|
+
`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`,
|
|
2204
2506
|
)
|
|
2205
2507
|
return EXIT.usage
|
|
2206
2508
|
}
|
|
@@ -2215,7 +2517,10 @@ export async function acknowledgeCommand(
|
|
|
2215
2517
|
const authed = authedClient(deps, config)
|
|
2216
2518
|
if (!authed) return EXIT.auth
|
|
2217
2519
|
try {
|
|
2218
|
-
const result = await authed.client.putAgentAcknowledgement(
|
|
2520
|
+
const result = await authed.client.putAgentAcknowledgement(
|
|
2521
|
+
requestId,
|
|
2522
|
+
text.length > 0 ? { text } : {},
|
|
2523
|
+
)
|
|
2219
2524
|
const output = {
|
|
2220
2525
|
request_id: requestId,
|
|
2221
2526
|
outcome: result.status,
|
|
@@ -2264,6 +2569,7 @@ export async function closeCommand(
|
|
|
2264
2569
|
acknowledgement_command: acknowledgementCommand(
|
|
2265
2570
|
response.request_id,
|
|
2266
2571
|
response.agent_acknowledgement_required,
|
|
2572
|
+
response.agent_acknowledgement_text_required,
|
|
2267
2573
|
response.agent_acknowledgement,
|
|
2268
2574
|
response.replies.length > 0,
|
|
2269
2575
|
),
|
|
@@ -2342,7 +2648,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2342
2648
|
if (flags.harness === undefined) {
|
|
2343
2649
|
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
2344
2650
|
if (detected.length === 0) {
|
|
2345
|
-
deps.io.err(`Could not tell which harness
|
|
2651
|
+
deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
|
|
2346
2652
|
return EXIT.usage
|
|
2347
2653
|
}
|
|
2348
2654
|
let ok = true
|
|
@@ -2467,6 +2773,13 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2467
2773
|
for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
|
|
2468
2774
|
deps.io.out(problem)
|
|
2469
2775
|
}
|
|
2776
|
+
for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
|
|
2777
|
+
deps.io.out(note)
|
|
2778
|
+
}
|
|
2779
|
+
if (flags.global) {
|
|
2780
|
+
const home = codexHomeNote(deps.env, hookPlatform)
|
|
2781
|
+
if (home !== null) deps.io.out(home)
|
|
2782
|
+
}
|
|
2470
2783
|
}
|
|
2471
2784
|
return EXIT.ok
|
|
2472
2785
|
}
|
|
@@ -2632,13 +2945,6 @@ function assertOwnedRegularFile(file: string): void {
|
|
|
2632
2945
|
}
|
|
2633
2946
|
}
|
|
2634
2947
|
|
|
2635
|
-
const HARNESS_LABEL: Record<Harness, string> = {
|
|
2636
|
-
'claude-code': 'Claude Code',
|
|
2637
|
-
codex: 'Codex',
|
|
2638
|
-
cursor: 'Cursor',
|
|
2639
|
-
opencode: 'OpenCode',
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
2948
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
2643
2949
|
if (requested !== undefined) {
|
|
2644
2950
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -2675,7 +2981,7 @@ async function pickHarnessesToInstall(
|
|
|
2675
2981
|
'Which agent harnesses should Notifai wire here?',
|
|
2676
2982
|
HARNESSES.map((name) => ({
|
|
2677
2983
|
value: name,
|
|
2678
|
-
label:
|
|
2984
|
+
label: HARNESS_LABELS[name],
|
|
2679
2985
|
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
2680
2986
|
})),
|
|
2681
2987
|
detected,
|
|
@@ -3148,8 +3454,8 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
|
|
|
3148
3454
|
query.since = since
|
|
3149
3455
|
}
|
|
3150
3456
|
if (flags.level !== undefined) {
|
|
3151
|
-
if (!(
|
|
3152
|
-
deps.io.err(`--level takes one of: ${
|
|
3457
|
+
if (!(RECORD_LEVELS as readonly string[]).includes(flags.level)) {
|
|
3458
|
+
deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`)
|
|
3153
3459
|
return EXIT.usage
|
|
3154
3460
|
}
|
|
3155
3461
|
query.level = flags.level as LogLevel
|
|
@@ -3294,14 +3600,9 @@ async function skillReadiness(
|
|
|
3294
3600
|
}
|
|
3295
3601
|
}
|
|
3296
3602
|
|
|
3297
|
-
/** Derive a contract-valid project slug
|
|
3603
|
+
/** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
|
|
3298
3604
|
export function projectSlugFrom(name: string): string {
|
|
3299
|
-
|
|
3300
|
-
.toLowerCase()
|
|
3301
|
-
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
3302
|
-
.replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
|
|
3303
|
-
.slice(0, 64)
|
|
3304
|
-
return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project'
|
|
3605
|
+
return inferredProjectSlugFrom(name) ?? 'project'
|
|
3305
3606
|
}
|
|
3306
3607
|
|
|
3307
3608
|
export interface InitFlags {
|
|
@@ -4036,7 +4337,11 @@ async function printInitClose(
|
|
|
4036
4337
|
]
|
|
4037
4338
|
await deps.io.note?.(lines.join('\n'), 'Ready')
|
|
4038
4339
|
printOptionalLeftovers(deps, leftovers)
|
|
4039
|
-
deps.io.out(
|
|
4340
|
+
deps.io.out(
|
|
4341
|
+
questions
|
|
4342
|
+
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
4343
|
+
: 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
|
|
4344
|
+
)
|
|
4040
4345
|
await deps.io.outro?.('All set ✨')
|
|
4041
4346
|
return
|
|
4042
4347
|
}
|
|
@@ -4052,7 +4357,11 @@ async function printInitClose(
|
|
|
4052
4357
|
|
|
4053
4358
|
if (blocker === null) {
|
|
4054
4359
|
printOptionalLeftovers(deps, leftovers)
|
|
4055
|
-
deps.io.out(
|
|
4360
|
+
deps.io.out(
|
|
4361
|
+
questions
|
|
4362
|
+
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
4363
|
+
: 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
|
|
4364
|
+
)
|
|
4056
4365
|
return
|
|
4057
4366
|
}
|
|
4058
4367
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
@@ -4106,29 +4415,40 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
4106
4415
|
}
|
|
4107
4416
|
|
|
4108
4417
|
function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
|
|
4109
|
-
const
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4418
|
+
const configured = config.project.value
|
|
4419
|
+
if (configured !== null) {
|
|
4420
|
+
return {
|
|
4421
|
+
id: 'project',
|
|
4422
|
+
title: 'Project identity',
|
|
4423
|
+
status: 'ready',
|
|
4424
|
+
detail: `"${configured}" (${config.project.source})`,
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
const inferred = inferInvocationContext(deps.cwd).project
|
|
4428
|
+
if (inferred !== null) {
|
|
4429
|
+
return {
|
|
4430
|
+
id: 'project',
|
|
4431
|
+
title: 'Project identity',
|
|
4432
|
+
status: 'optional-gap',
|
|
4433
|
+
detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
|
|
4434
|
+
remedy: {
|
|
4435
|
+
by: 'cli',
|
|
4436
|
+
summary: 'make the inferred Project identity explicit for every checkout',
|
|
4437
|
+
command: 'notifai init',
|
|
4438
|
+
},
|
|
4439
|
+
}
|
|
4440
|
+
}
|
|
4441
|
+
return {
|
|
4442
|
+
id: 'project',
|
|
4443
|
+
title: 'Project identity',
|
|
4444
|
+
status: 'optional-gap',
|
|
4445
|
+
detail: 'the directory name has no characters a Project identifier can use',
|
|
4446
|
+
remedy: {
|
|
4447
|
+
by: 'cli',
|
|
4448
|
+
summary: 'choose an explicit Project identifier',
|
|
4449
|
+
command: 'notifai init --project-id my-project',
|
|
4450
|
+
},
|
|
4451
|
+
}
|
|
4132
4452
|
}
|
|
4133
4453
|
|
|
4134
4454
|
function remoteStatesFrom(previous: Readiness): {
|
|
@@ -4627,15 +4947,41 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
4627
4947
|
]
|
|
4628
4948
|
}
|
|
4629
4949
|
|
|
4950
|
+
/**
|
|
4951
|
+
* A human title per check.
|
|
4952
|
+
*
|
|
4953
|
+
* Three checks used to collapse onto "Question routing", so a reader could not
|
|
4954
|
+
* tell which of them had failed, and the rest fell through to their internal
|
|
4955
|
+
* name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
|
|
4956
|
+
* thing to branch on; this is only what a person reads.
|
|
4957
|
+
*/
|
|
4958
|
+
const CHECK_TITLES: Readonly<Record<string, string>> = {
|
|
4959
|
+
hooks: 'Question routing',
|
|
4960
|
+
'hooks (detected)': 'Harnesses detected',
|
|
4961
|
+
'hooks (active harness)': 'Routing for this harness',
|
|
4962
|
+
'hooks (active session)': 'Routing for this session',
|
|
4963
|
+
'hooks (stale)': 'Hook definitions current',
|
|
4964
|
+
'hooks (adapter)': 'Hook adapter',
|
|
4965
|
+
'hooks (trust)': 'Codex hook trust',
|
|
4966
|
+
'hooks (stop shape)': 'Turn-end hook shape',
|
|
4967
|
+
'hooks (duplicates)': 'Duplicate hook installs',
|
|
4968
|
+
'hooks (codex representation)': 'Codex hook representation',
|
|
4969
|
+
'hooks (question admission)': 'Question admission',
|
|
4970
|
+
'hooks (fired)': 'Hooks have run here',
|
|
4971
|
+
'hooks (answer continuation)': 'How an answer returns',
|
|
4972
|
+
'hooks (wake route)': 'Direct wake route',
|
|
4973
|
+
}
|
|
4974
|
+
|
|
4975
|
+
function checkTitle(name: string): string {
|
|
4976
|
+
return CHECK_TITLES[name] ?? name
|
|
4977
|
+
}
|
|
4978
|
+
|
|
4630
4979
|
/** Real but not in the way; see the note above. */
|
|
4631
4980
|
const informational = new Set<string>()
|
|
4632
4981
|
return [
|
|
4633
4982
|
...hookChecks(deps).map((check) => ({
|
|
4634
4983
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
4635
|
-
title:
|
|
4636
|
-
check.name === 'hooks' || check.name.startsWith('hooks (active')
|
|
4637
|
-
? 'Question routing'
|
|
4638
|
-
: check.name,
|
|
4984
|
+
title: checkTitle(check.name),
|
|
4639
4985
|
status: check.ok
|
|
4640
4986
|
? 'ready' as const
|
|
4641
4987
|
: check.informational === true || informational.has(check.name)
|
|
@@ -4685,7 +5031,7 @@ function stopShapeProblems(
|
|
|
4685
5031
|
for (const handler of installation.handlers.filter(
|
|
4686
5032
|
(entry) => handlerEvent(entry.command) === 'stop',
|
|
4687
5033
|
)) {
|
|
4688
|
-
if (installation.harness
|
|
5034
|
+
if (stopHandlerIsDetached(installation.harness, platform)) {
|
|
4689
5035
|
if (handler.async !== true) {
|
|
4690
5036
|
problems.push(
|
|
4691
5037
|
`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
|
|
@@ -4746,7 +5092,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4746
5092
|
name: 'hooks (detected)',
|
|
4747
5093
|
ok: false,
|
|
4748
5094
|
informational: true,
|
|
4749
|
-
detail: `${unwired.map((harness) =>
|
|
5095
|
+
detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
|
|
4750
5096
|
remedy: {
|
|
4751
5097
|
summary: 'install hooks for every detected harness',
|
|
4752
5098
|
command: 'notifai hooks install',
|
|
@@ -4922,6 +5268,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4922
5268
|
ok: false,
|
|
4923
5269
|
detail: representationProblems.join('; '),
|
|
4924
5270
|
})
|
|
5271
|
+
} else {
|
|
5272
|
+
const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform)
|
|
5273
|
+
if (coexistence.length > 0) {
|
|
5274
|
+
checks.push({
|
|
5275
|
+
name: 'hooks (codex representation)',
|
|
5276
|
+
ok: true,
|
|
5277
|
+
detail: coexistence.join('; '),
|
|
5278
|
+
})
|
|
5279
|
+
}
|
|
5280
|
+
}
|
|
5281
|
+
|
|
5282
|
+
const codexHome = codexHomeNote(deps.env, deps.hookPlatform)
|
|
5283
|
+
if (codexHome !== null) {
|
|
5284
|
+
checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome })
|
|
4925
5285
|
}
|
|
4926
5286
|
|
|
4927
5287
|
if (active !== null && activeInstallations.length > 0) {
|