@raidiant/notifai 4.0.0 → 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 +46 -0
- package/README.md +5 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +174 -58
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +14 -5
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -0
- 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 +72 -5
- package/dist/hooks.js +407 -79
- 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/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +44 -17
- 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 +6 -0
- package/dist/send.js +25 -14
- 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 +185 -51
- package/src/commands.ts +198 -56
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +16 -5
- package/src/config.test.ts +41 -9
- package/src/config.ts +13 -0
- 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 +358 -21
- package/src/hooks.ts +457 -97
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- 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 +57 -23
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- 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,
|
|
@@ -32,7 +33,6 @@ import {
|
|
|
32
33
|
import {
|
|
33
34
|
BOOLEAN_CONFIG_KEYS,
|
|
34
35
|
CONFIG_KEYS,
|
|
35
|
-
LOG_LEVELS,
|
|
36
36
|
NUMERIC_CONFIG_KEYS,
|
|
37
37
|
configBounds,
|
|
38
38
|
configDefaultValue,
|
|
@@ -66,7 +66,7 @@ import {
|
|
|
66
66
|
} from './readiness.js'
|
|
67
67
|
import type { Tone } from './ui/theme.js'
|
|
68
68
|
import {
|
|
69
|
-
|
|
69
|
+
waiterCeilingSeconds,
|
|
70
70
|
clearAcknowledgementObligation,
|
|
71
71
|
handleSessionEnd,
|
|
72
72
|
handleStop,
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
type EscalationDeliveryRoute,
|
|
83
83
|
type HookContext,
|
|
84
84
|
type HookHarness,
|
|
85
|
+
MIN_REPLY_WINDOW_SECONDS,
|
|
85
86
|
} from './hooks.js'
|
|
86
87
|
import {
|
|
87
88
|
LOG_EVENTS,
|
|
@@ -95,9 +96,11 @@ import {
|
|
|
95
96
|
renderRecord,
|
|
96
97
|
type LogQuery,
|
|
97
98
|
type Logger,
|
|
99
|
+
RECORD_LEVELS,
|
|
98
100
|
} from './logging.js'
|
|
99
101
|
import {
|
|
100
102
|
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
103
|
+
stopHandlerIsDetached,
|
|
101
104
|
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
102
105
|
HARNESSES,
|
|
103
106
|
applyPlan,
|
|
@@ -105,6 +108,8 @@ import {
|
|
|
105
108
|
buildHookConfig,
|
|
106
109
|
codexLayerDir,
|
|
107
110
|
codexLayerPaths,
|
|
111
|
+
codexCoexistenceNotes,
|
|
112
|
+
codexHomeNote,
|
|
108
113
|
codexRepresentationProblems,
|
|
109
114
|
codexTrustProblems,
|
|
110
115
|
codexProjectRoot,
|
|
@@ -319,7 +324,9 @@ function reportError(
|
|
|
319
324
|
if (paths.length > 0) deps.io.err(`the server rejected: ${paths.join(', ')}`)
|
|
320
325
|
// A 422 on a request this CLI built is not a user error: the two sides
|
|
321
326
|
// disagree about the contract, in whichever direction. `doctor` says which.
|
|
322
|
-
|
|
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') {
|
|
323
330
|
deps.io.err(
|
|
324
331
|
'this build sent a field the server did not accept — the CLI and server ' +
|
|
325
332
|
'disagree about the contract; check with `notifai doctor`',
|
|
@@ -565,6 +572,13 @@ export async function capabilitiesCommand(
|
|
|
565
572
|
deps: CommandDeps,
|
|
566
573
|
flags: { json?: boolean; platform?: Platform },
|
|
567
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
|
+
}
|
|
568
582
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
569
583
|
const credential = deps.store.load()
|
|
570
584
|
const baseUrl = resolvedBaseUrl(config, credential)
|
|
@@ -638,6 +652,17 @@ export async function sendCommand(
|
|
|
638
652
|
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
|
|
639
653
|
return EXIT.usage
|
|
640
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
|
+
}
|
|
641
666
|
if (!flags.reply && flags.kind === 'question') {
|
|
642
667
|
deps.io.err('--kind question requires --reply so the question can be answered.')
|
|
643
668
|
return EXIT.usage
|
|
@@ -676,10 +701,12 @@ export async function sendCommand(
|
|
|
676
701
|
flags.reply &&
|
|
677
702
|
flags.replyWindow !== undefined &&
|
|
678
703
|
(!Number.isInteger(flags.replyWindow) ||
|
|
679
|
-
flags.replyWindow <
|
|
704
|
+
flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
|
|
680
705
|
flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
|
|
681
706
|
) {
|
|
682
|
-
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
|
+
)
|
|
683
710
|
return EXIT.usage
|
|
684
711
|
}
|
|
685
712
|
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
@@ -711,7 +738,7 @@ export async function sendCommand(
|
|
|
711
738
|
}
|
|
712
739
|
const uploaded = await uploadImage(deps, authed.client, image)
|
|
713
740
|
if (!uploaded.ok) {
|
|
714
|
-
deps.io.err(uploaded.error)
|
|
741
|
+
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
715
742
|
return uploaded.exit
|
|
716
743
|
}
|
|
717
744
|
mediaIds.push(uploaded.mediaId)
|
|
@@ -794,6 +821,7 @@ export async function sendCommand(
|
|
|
794
821
|
request_id: receipt.request_id,
|
|
795
822
|
replies: [],
|
|
796
823
|
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
824
|
+
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
797
825
|
agent_acknowledgement: null,
|
|
798
826
|
acknowledgement_command: null,
|
|
799
827
|
degraded: false,
|
|
@@ -935,7 +963,14 @@ export async function repliesCommand(
|
|
|
935
963
|
.map((entry) => entry.request_id)
|
|
936
964
|
.filter((id): id is string => id !== undefined)
|
|
937
965
|
if (requestIds.length === 0) {
|
|
938
|
-
|
|
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
|
+
}
|
|
939
974
|
return EXIT.noReply
|
|
940
975
|
}
|
|
941
976
|
}
|
|
@@ -1071,6 +1106,7 @@ export async function waitForReply(
|
|
|
1071
1106
|
request_id: requestId,
|
|
1072
1107
|
reply_expires_at: null,
|
|
1073
1108
|
agent_acknowledgement_required: false,
|
|
1109
|
+
agent_acknowledgement_text_required: false,
|
|
1074
1110
|
agent_acknowledgement: null,
|
|
1075
1111
|
replies: [],
|
|
1076
1112
|
} satisfies ListRepliesResponse),
|
|
@@ -1113,14 +1149,21 @@ function isNonNegativeInteger(value: number): boolean {
|
|
|
1113
1149
|
return Number.isInteger(value) && value >= 0
|
|
1114
1150
|
}
|
|
1115
1151
|
|
|
1152
|
+
function acknowledgeInvocation(requestId: string, textRequired: boolean): string {
|
|
1153
|
+
return textRequired
|
|
1154
|
+
? `notifai acknowledge ${requestId} --text <text>`
|
|
1155
|
+
: `notifai acknowledge ${requestId}`
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1116
1158
|
function acknowledgementCommand(
|
|
1117
1159
|
requestId: string,
|
|
1118
1160
|
required: boolean,
|
|
1161
|
+
textRequired: boolean,
|
|
1119
1162
|
acknowledgement: ListRepliesResponse['agent_acknowledgement'],
|
|
1120
1163
|
hasReply = true,
|
|
1121
1164
|
): string | null {
|
|
1122
1165
|
return required && acknowledgement === null && hasReply
|
|
1123
|
-
?
|
|
1166
|
+
? acknowledgeInvocation(requestId, textRequired)
|
|
1124
1167
|
: null
|
|
1125
1168
|
}
|
|
1126
1169
|
|
|
@@ -1132,9 +1175,11 @@ function replyResultJson(response: ListRepliesResponse, degraded: boolean): obje
|
|
|
1132
1175
|
replies: response.replies,
|
|
1133
1176
|
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
1134
1177
|
agent_acknowledgement: response.agent_acknowledgement,
|
|
1178
|
+
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
1135
1179
|
acknowledgement_command: acknowledgementCommand(
|
|
1136
1180
|
response.request_id,
|
|
1137
1181
|
response.agent_acknowledgement_required,
|
|
1182
|
+
response.agent_acknowledgement_text_required,
|
|
1138
1183
|
response.agent_acknowledgement,
|
|
1139
1184
|
response.replies.length > 0,
|
|
1140
1185
|
),
|
|
@@ -1147,16 +1192,22 @@ function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResp
|
|
|
1147
1192
|
deps.io.out('Agent Acknowledgement: not required for this request.')
|
|
1148
1193
|
return
|
|
1149
1194
|
}
|
|
1195
|
+
const textRequired = response.agent_acknowledgement_text_required
|
|
1150
1196
|
if (response.agent_acknowledgement !== null) {
|
|
1197
|
+
const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`
|
|
1151
1198
|
deps.io.out(
|
|
1152
|
-
|
|
1199
|
+
response.agent_acknowledgement.text.length > 0
|
|
1200
|
+
? `${recorded}: ${response.agent_acknowledgement.text}`
|
|
1201
|
+
: `${recorded}.`,
|
|
1153
1202
|
)
|
|
1154
1203
|
return
|
|
1155
1204
|
}
|
|
1156
1205
|
if (response.replies.length > 0) {
|
|
1157
1206
|
deps.io.out('Agent Acknowledgement required.')
|
|
1158
1207
|
deps.io.out(
|
|
1159
|
-
|
|
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.`,
|
|
1160
1211
|
)
|
|
1161
1212
|
return
|
|
1162
1213
|
}
|
|
@@ -1296,7 +1347,8 @@ const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
|
|
|
1296
1347
|
|
|
1297
1348
|
type UploadResult =
|
|
1298
1349
|
| { ok: true; mediaId: string }
|
|
1299
|
-
|
|
1350
|
+
/** `error: null` means `reportError` already said it; do not print it twice. */
|
|
1351
|
+
| { ok: false; error: string | null; exit: number }
|
|
1300
1352
|
|
|
1301
1353
|
/** `--image` accepts a media id, a local file path, or an http(s) URL. */
|
|
1302
1354
|
async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
|
|
@@ -1331,7 +1383,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
|
|
|
1331
1383
|
await client.uploadMedia(grant, bytes)
|
|
1332
1384
|
return { ok: true, mediaId: grant.media_id }
|
|
1333
1385
|
} catch (err) {
|
|
1334
|
-
|
|
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) }
|
|
1335
1393
|
}
|
|
1336
1394
|
}
|
|
1337
1395
|
|
|
@@ -1374,7 +1432,11 @@ export async function hookRunCommand(
|
|
|
1374
1432
|
// would grant slow setup a second budget and let the harness kill us before
|
|
1375
1433
|
// an accepted answer is journaled or written to stdout.
|
|
1376
1434
|
const now = deps.now ?? Date.now
|
|
1377
|
-
|
|
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
|
|
1378
1440
|
|
|
1379
1441
|
const logger = log(deps)
|
|
1380
1442
|
logger.bind({ cmd: `hook ${event}` })
|
|
@@ -1567,20 +1629,7 @@ export async function hookRunCommand(
|
|
|
1567
1629
|
...outcome.log,
|
|
1568
1630
|
})
|
|
1569
1631
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1570
|
-
if (outcome.stdout !== undefined)
|
|
1571
|
-
let stdout = outcome.stdout
|
|
1572
|
-
if (
|
|
1573
|
-
harness !== undefined &&
|
|
1574
|
-
event === 'stop' &&
|
|
1575
|
-
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
|
|
1576
|
-
) {
|
|
1577
|
-
const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
|
|
1578
|
-
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
1579
|
-
stdout = JSON.stringify({ followup_message: decision.reason })
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
deps.io.out(stdout)
|
|
1583
|
-
}
|
|
1632
|
+
if (outcome.stdout !== undefined) deps.io.out(outcome.stdout)
|
|
1584
1633
|
return EXIT.ok
|
|
1585
1634
|
} catch (err) {
|
|
1586
1635
|
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
@@ -1700,6 +1749,8 @@ function rejectedPaths(details: unknown): string[] {
|
|
|
1700
1749
|
}
|
|
1701
1750
|
|
|
1702
1751
|
export interface AskFlags {
|
|
1752
|
+
/** Emit the registration and its turn obligation as one JSON object. */
|
|
1753
|
+
json?: boolean
|
|
1703
1754
|
choice?: string[]
|
|
1704
1755
|
/** The single question is multi-select: several answers may be chosen. */
|
|
1705
1756
|
multi?: boolean
|
|
@@ -1749,7 +1800,7 @@ export function buildQuestions(
|
|
|
1749
1800
|
}
|
|
1750
1801
|
}
|
|
1751
1802
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
1752
|
-
return { ok: false, error:
|
|
1803
|
+
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
|
|
1753
1804
|
}
|
|
1754
1805
|
const record = parsed as Record<string, unknown>
|
|
1755
1806
|
const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
|
|
@@ -1760,7 +1811,7 @@ export function buildQuestions(
|
|
|
1760
1811
|
}
|
|
1761
1812
|
}
|
|
1762
1813
|
if (!Array.isArray(record['questions'])) {
|
|
1763
|
-
return { ok: false, error:
|
|
1814
|
+
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
|
|
1764
1815
|
}
|
|
1765
1816
|
const formQuestions = record['questions']
|
|
1766
1817
|
if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
|
|
@@ -1886,6 +1937,7 @@ function recordRegisteredQuestion(
|
|
|
1886
1937
|
sessionId: string,
|
|
1887
1938
|
built: BuiltQuestions,
|
|
1888
1939
|
draft: NotificationDraftT,
|
|
1940
|
+
json = false,
|
|
1889
1941
|
): number {
|
|
1890
1942
|
try {
|
|
1891
1943
|
registerQuestion(
|
|
@@ -1914,6 +1966,37 @@ function recordRegisteredQuestion(
|
|
|
1914
1966
|
choices: built.questions[0]!.choices?.length ?? 0,
|
|
1915
1967
|
media: draft.presentation.media?.length ?? 0,
|
|
1916
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
|
+
}
|
|
1917
2000
|
for (const [index, entry] of built.questions.entries()) {
|
|
1918
2001
|
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1919
2002
|
if (entry.choices !== undefined) {
|
|
@@ -1973,7 +2056,7 @@ async function uploadAskMedia(
|
|
|
1973
2056
|
}
|
|
1974
2057
|
const uploaded = await uploadImage(deps, authed.client, image)
|
|
1975
2058
|
if (!uploaded.ok) {
|
|
1976
|
-
deps.io.err(uploaded.error)
|
|
2059
|
+
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
1977
2060
|
return uploaded.exit
|
|
1978
2061
|
}
|
|
1979
2062
|
mediaIds.push(uploaded.mediaId)
|
|
@@ -1983,7 +2066,7 @@ async function uploadAskMedia(
|
|
|
1983
2066
|
deps.io.err(ready.error)
|
|
1984
2067
|
return EXIT.usage
|
|
1985
2068
|
}
|
|
1986
|
-
return recordRegisteredQuestion(deps, sessionId, built, ready.draft)
|
|
2069
|
+
return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
|
|
1987
2070
|
}
|
|
1988
2071
|
|
|
1989
2072
|
/**
|
|
@@ -2121,7 +2204,7 @@ export function askCommand(
|
|
|
2121
2204
|
source.invocation,
|
|
2122
2205
|
)
|
|
2123
2206
|
}
|
|
2124
|
-
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft)
|
|
2207
|
+
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true)
|
|
2125
2208
|
}
|
|
2126
2209
|
|
|
2127
2210
|
/**
|
|
@@ -2399,20 +2482,27 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
2399
2482
|
return `${advice.join('. ')}.`
|
|
2400
2483
|
}
|
|
2401
2484
|
|
|
2402
|
-
/**
|
|
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
|
+
*/
|
|
2403
2493
|
export async function acknowledgeCommand(
|
|
2404
2494
|
deps: CommandDeps,
|
|
2405
2495
|
requestId: string,
|
|
2406
2496
|
flags: { text?: string; json?: boolean },
|
|
2407
2497
|
): Promise<number> {
|
|
2408
2498
|
const text = flags.text?.trim() ?? ''
|
|
2409
|
-
if (text.length === 0) {
|
|
2410
|
-
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.')
|
|
2411
2501
|
return EXIT.usage
|
|
2412
2502
|
}
|
|
2413
2503
|
if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
|
|
2414
2504
|
deps.io.err(
|
|
2415
|
-
`--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.`,
|
|
2416
2506
|
)
|
|
2417
2507
|
return EXIT.usage
|
|
2418
2508
|
}
|
|
@@ -2427,7 +2517,10 @@ export async function acknowledgeCommand(
|
|
|
2427
2517
|
const authed = authedClient(deps, config)
|
|
2428
2518
|
if (!authed) return EXIT.auth
|
|
2429
2519
|
try {
|
|
2430
|
-
const result = await authed.client.putAgentAcknowledgement(
|
|
2520
|
+
const result = await authed.client.putAgentAcknowledgement(
|
|
2521
|
+
requestId,
|
|
2522
|
+
text.length > 0 ? { text } : {},
|
|
2523
|
+
)
|
|
2431
2524
|
const output = {
|
|
2432
2525
|
request_id: requestId,
|
|
2433
2526
|
outcome: result.status,
|
|
@@ -2476,6 +2569,7 @@ export async function closeCommand(
|
|
|
2476
2569
|
acknowledgement_command: acknowledgementCommand(
|
|
2477
2570
|
response.request_id,
|
|
2478
2571
|
response.agent_acknowledgement_required,
|
|
2572
|
+
response.agent_acknowledgement_text_required,
|
|
2479
2573
|
response.agent_acknowledgement,
|
|
2480
2574
|
response.replies.length > 0,
|
|
2481
2575
|
),
|
|
@@ -2554,7 +2648,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2554
2648
|
if (flags.harness === undefined) {
|
|
2555
2649
|
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
2556
2650
|
if (detected.length === 0) {
|
|
2557
|
-
deps.io.err(`Could not tell which harness
|
|
2651
|
+
deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
|
|
2558
2652
|
return EXIT.usage
|
|
2559
2653
|
}
|
|
2560
2654
|
let ok = true
|
|
@@ -2679,6 +2773,13 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2679
2773
|
for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
|
|
2680
2774
|
deps.io.out(problem)
|
|
2681
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
|
+
}
|
|
2682
2783
|
}
|
|
2683
2784
|
return EXIT.ok
|
|
2684
2785
|
}
|
|
@@ -2844,13 +2945,6 @@ function assertOwnedRegularFile(file: string): void {
|
|
|
2844
2945
|
}
|
|
2845
2946
|
}
|
|
2846
2947
|
|
|
2847
|
-
const HARNESS_LABEL: Record<Harness, string> = {
|
|
2848
|
-
'claude-code': 'Claude Code',
|
|
2849
|
-
codex: 'Codex',
|
|
2850
|
-
cursor: 'Cursor',
|
|
2851
|
-
opencode: 'OpenCode',
|
|
2852
|
-
}
|
|
2853
|
-
|
|
2854
2948
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
2855
2949
|
if (requested !== undefined) {
|
|
2856
2950
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -2887,7 +2981,7 @@ async function pickHarnessesToInstall(
|
|
|
2887
2981
|
'Which agent harnesses should Notifai wire here?',
|
|
2888
2982
|
HARNESSES.map((name) => ({
|
|
2889
2983
|
value: name,
|
|
2890
|
-
label:
|
|
2984
|
+
label: HARNESS_LABELS[name],
|
|
2891
2985
|
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
2892
2986
|
})),
|
|
2893
2987
|
detected,
|
|
@@ -3360,8 +3454,8 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
|
|
|
3360
3454
|
query.since = since
|
|
3361
3455
|
}
|
|
3362
3456
|
if (flags.level !== undefined) {
|
|
3363
|
-
if (!(
|
|
3364
|
-
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}".`)
|
|
3365
3459
|
return EXIT.usage
|
|
3366
3460
|
}
|
|
3367
3461
|
query.level = flags.level as LogLevel
|
|
@@ -4243,7 +4337,11 @@ async function printInitClose(
|
|
|
4243
4337
|
]
|
|
4244
4338
|
await deps.io.note?.(lines.join('\n'), 'Ready')
|
|
4245
4339
|
printOptionalLeftovers(deps, leftovers)
|
|
4246
|
-
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
|
+
)
|
|
4247
4345
|
await deps.io.outro?.('All set ✨')
|
|
4248
4346
|
return
|
|
4249
4347
|
}
|
|
@@ -4259,7 +4357,11 @@ async function printInitClose(
|
|
|
4259
4357
|
|
|
4260
4358
|
if (blocker === null) {
|
|
4261
4359
|
printOptionalLeftovers(deps, leftovers)
|
|
4262
|
-
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
|
+
)
|
|
4263
4365
|
return
|
|
4264
4366
|
}
|
|
4265
4367
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
@@ -4845,15 +4947,41 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
4845
4947
|
]
|
|
4846
4948
|
}
|
|
4847
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
|
+
|
|
4848
4979
|
/** Real but not in the way; see the note above. */
|
|
4849
4980
|
const informational = new Set<string>()
|
|
4850
4981
|
return [
|
|
4851
4982
|
...hookChecks(deps).map((check) => ({
|
|
4852
4983
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
4853
|
-
title:
|
|
4854
|
-
check.name === 'hooks' || check.name.startsWith('hooks (active')
|
|
4855
|
-
? 'Question routing'
|
|
4856
|
-
: check.name,
|
|
4984
|
+
title: checkTitle(check.name),
|
|
4857
4985
|
status: check.ok
|
|
4858
4986
|
? 'ready' as const
|
|
4859
4987
|
: check.informational === true || informational.has(check.name)
|
|
@@ -4903,7 +5031,7 @@ function stopShapeProblems(
|
|
|
4903
5031
|
for (const handler of installation.handlers.filter(
|
|
4904
5032
|
(entry) => handlerEvent(entry.command) === 'stop',
|
|
4905
5033
|
)) {
|
|
4906
|
-
if (installation.harness
|
|
5034
|
+
if (stopHandlerIsDetached(installation.harness, platform)) {
|
|
4907
5035
|
if (handler.async !== true) {
|
|
4908
5036
|
problems.push(
|
|
4909
5037
|
`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
|
|
@@ -4964,7 +5092,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4964
5092
|
name: 'hooks (detected)',
|
|
4965
5093
|
ok: false,
|
|
4966
5094
|
informational: true,
|
|
4967
|
-
detail: `${unwired.map((harness) =>
|
|
5095
|
+
detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
|
|
4968
5096
|
remedy: {
|
|
4969
5097
|
summary: 'install hooks for every detected harness',
|
|
4970
5098
|
command: 'notifai hooks install',
|
|
@@ -5140,6 +5268,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
5140
5268
|
ok: false,
|
|
5141
5269
|
detail: representationProblems.join('; '),
|
|
5142
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 })
|
|
5143
5285
|
}
|
|
5144
5286
|
|
|
5145
5287
|
if (active !== null && activeInstallations.length > 0) {
|
|
@@ -86,7 +86,7 @@ describe('formatValue', () => {
|
|
|
86
86
|
it('explains what an unset value means instead of printing null', () => {
|
|
87
87
|
// `sound = null` is accurate and tells the reader nothing about what will
|
|
88
88
|
// happen; the whole complaint about the old output in one line.
|
|
89
|
-
expect(formatValue('sound', null)).toBe('not
|
|
89
|
+
expect(formatValue('sound', null)).toBe('not set; each kind brings its own sound')
|
|
90
90
|
expect(formatValue('devices', null)).toBe('every device that can receive')
|
|
91
91
|
})
|
|
92
92
|
|
package/src/config-schema.ts
CHANGED
|
@@ -85,7 +85,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
85
85
|
kind: 'boolean',
|
|
86
86
|
summary: 'Whether a registered question may reach your phone and Mac at all',
|
|
87
87
|
detail:
|
|
88
|
-
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever
|
|
88
|
+
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
89
89
|
example: 'true',
|
|
90
90
|
},
|
|
91
91
|
ask_grace_seconds: {
|
|
@@ -99,6 +99,17 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
99
99
|
example: '0',
|
|
100
100
|
},
|
|
101
101
|
|
|
102
|
+
reply_window_seconds: {
|
|
103
|
+
label: 'How long an answer is accepted',
|
|
104
|
+
group: 'questions',
|
|
105
|
+
kind: 'integer',
|
|
106
|
+
unit: 's',
|
|
107
|
+
summary: 'How long the service keeps accepting your answer to a question',
|
|
108
|
+
detail:
|
|
109
|
+
'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
|
|
110
|
+
example: '86400',
|
|
111
|
+
},
|
|
112
|
+
|
|
102
113
|
sound: {
|
|
103
114
|
label: 'Sound',
|
|
104
115
|
group: 'delivery',
|
|
@@ -113,8 +124,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
113
124
|
},
|
|
114
125
|
summary: 'Set the sound notifications from here use',
|
|
115
126
|
detail:
|
|
116
|
-
'Setting this pins one sound for every notification sent from here
|
|
117
|
-
unsetMeans: 'not
|
|
127
|
+
'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and the standard tone for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
|
|
128
|
+
unsetMeans: 'not set; each kind brings its own sound',
|
|
118
129
|
},
|
|
119
130
|
interruption_level: {
|
|
120
131
|
label: 'Interruption level',
|
|
@@ -124,11 +135,11 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
124
135
|
choiceHints: {
|
|
125
136
|
passive: 'no sound or wake — waits in the notification list',
|
|
126
137
|
active: 'wakes the screen and plays the sound',
|
|
127
|
-
time_sensitive: '
|
|
138
|
+
time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
|
|
128
139
|
},
|
|
129
140
|
summary: 'Set how insistently notifications from here arrive',
|
|
130
141
|
detail:
|
|
131
|
-
'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind never
|
|
142
|
+
'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
|
|
132
143
|
unsetMeans: 'not specified; the destination uses its normal behavior',
|
|
133
144
|
},
|
|
134
145
|
devices: {
|