@raidiant/notifai 0.1.8 → 0.2.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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +118 -37
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +7 -5
- package/dist/hooks.js +60 -19
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +9 -9
- package/src/commands.test.ts +108 -13
- package/src/commands.ts +144 -37
- package/src/hooks.test.ts +102 -4
- package/src/hooks.ts +79 -24
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/src/hooks.test.ts
CHANGED
|
@@ -127,7 +127,8 @@ function reply(overrides: Partial<ReplyView> = {}): ReplyView {
|
|
|
127
127
|
device_id: 'dev_test',
|
|
128
128
|
device_name: 'Furankuphone',
|
|
129
129
|
text: 'Allow',
|
|
130
|
-
|
|
130
|
+
answers: [{ question_id: 'q1', choice_ids: ['allow'], text: null }],
|
|
131
|
+
source: null,
|
|
131
132
|
created_at: new Date().toISOString(),
|
|
132
133
|
...overrides,
|
|
133
134
|
}
|
|
@@ -915,11 +916,11 @@ describe('ask registration', () => {
|
|
|
915
916
|
const h = harness()
|
|
916
917
|
// Inside a hook, a rejection is only a stderr note the agent never reads —
|
|
917
918
|
// so it would look registered and then silently never ask.
|
|
918
|
-
expect(askCommand(h.deps, 'Ship it?', { choice: '
|
|
919
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
|
|
919
920
|
expect(readSessionState('a1', h.env).pending).toBeUndefined()
|
|
920
921
|
})
|
|
921
922
|
|
|
922
|
-
it('stores validated
|
|
923
|
+
it('stores the validated question set, comma-bearing labels verbatim', () => {
|
|
923
924
|
const h = harness()
|
|
924
925
|
expect(
|
|
925
926
|
askCommand(h.deps, 'Ship it?', {
|
|
@@ -927,7 +928,104 @@ describe('ask registration', () => {
|
|
|
927
928
|
session: 'a2',
|
|
928
929
|
}),
|
|
929
930
|
).toBe(EXIT.ok)
|
|
930
|
-
expect(readSessionState('a2', h.env).pending?.
|
|
931
|
+
expect(readSessionState('a2', h.env).pending?.questions).toEqual([
|
|
932
|
+
{
|
|
933
|
+
id: 'ship-it',
|
|
934
|
+
text: 'Ship it?',
|
|
935
|
+
choices: [
|
|
936
|
+
{ id: 'yes-ship-it', label: 'Yes, ship it' },
|
|
937
|
+
{ id: 'no-hold', label: 'No, hold' },
|
|
938
|
+
],
|
|
939
|
+
},
|
|
940
|
+
])
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
it('registers a multi-question form and pushes it as one set', async () => {
|
|
944
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
945
|
+
writeSessionState('form1', h.env, { last_prompt_at: AWAY })
|
|
946
|
+
expect(
|
|
947
|
+
askCommand(h.deps, undefined, {
|
|
948
|
+
session: 'form1',
|
|
949
|
+
form: JSON.stringify({
|
|
950
|
+
questions: [
|
|
951
|
+
{ text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
|
|
952
|
+
{ text: 'Anything to watch?' },
|
|
953
|
+
],
|
|
954
|
+
detail: '## Context\nThe long story.',
|
|
955
|
+
}),
|
|
956
|
+
}),
|
|
957
|
+
).toBe(EXIT.ok)
|
|
958
|
+
const pending = readSessionState('form1', h.env).pending
|
|
959
|
+
expect(pending?.questions).toHaveLength(2)
|
|
960
|
+
expect(pending?.detail).toContain('long story')
|
|
961
|
+
|
|
962
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
|
|
963
|
+
const draft = h.recorder.submitted[0]?.draft
|
|
964
|
+
// The banner leads with the first question and admits the rest exists;
|
|
965
|
+
// the full set rides the payload for the answering card.
|
|
966
|
+
expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
|
|
967
|
+
expect(draft?.presentation.detail).toContain('long story')
|
|
968
|
+
expect(draft?.reply?.questions).toEqual([
|
|
969
|
+
{
|
|
970
|
+
id: 'deploy-where',
|
|
971
|
+
text: 'Deploy where?',
|
|
972
|
+
choices: [
|
|
973
|
+
{ id: 'staging', label: 'Staging' },
|
|
974
|
+
{ id: 'production', label: 'Production' },
|
|
975
|
+
],
|
|
976
|
+
multi: true,
|
|
977
|
+
},
|
|
978
|
+
{ id: 'anything-to-watch', text: 'Anything to watch?' },
|
|
979
|
+
])
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
it('acts on the latest reply when answers conflict, because it is a correction', async () => {
|
|
983
|
+
const h = harness(
|
|
984
|
+
[
|
|
985
|
+
reply({ seq: 1, text: 'Yes', answers: [{ question_id: 'ship-it', choice_ids: ['yes'], text: null }] }),
|
|
986
|
+
reply({
|
|
987
|
+
seq: 2,
|
|
988
|
+
reply_id: 'rpl_2',
|
|
989
|
+
text: 'No',
|
|
990
|
+
answers: [{ question_id: 'ship-it', choice_ids: ['no'], text: null }],
|
|
991
|
+
}),
|
|
992
|
+
],
|
|
993
|
+
900,
|
|
994
|
+
)
|
|
995
|
+
writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
|
|
996
|
+
expect(
|
|
997
|
+
askCommand(h.deps, 'Ship it?', { session: 'latest1', choice: ['Yes', 'No'] }),
|
|
998
|
+
).toBe(EXIT.ok)
|
|
999
|
+
|
|
1000
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
|
|
1001
|
+
|
|
1002
|
+
const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
|
|
1003
|
+
expect(decision.decision).toBe('block')
|
|
1004
|
+
expect(decision.reason).toContain('"No"')
|
|
1005
|
+
expect(decision.reason).not.toContain('"Yes"')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('hands a free-text answer written in parts to the agent in order', async () => {
|
|
1009
|
+
const h = harness(
|
|
1010
|
+
[
|
|
1011
|
+
reply({ seq: 1, text: 'Use blue', answers: [{ question_id: 'which-color', choice_ids: [], text: 'Use blue' }] }),
|
|
1012
|
+
reply({
|
|
1013
|
+
seq: 2,
|
|
1014
|
+
reply_id: 'rpl_2',
|
|
1015
|
+
text: 'actually teal',
|
|
1016
|
+
answers: [{ question_id: 'which-color', choice_ids: [], text: 'actually teal' }],
|
|
1017
|
+
}),
|
|
1018
|
+
],
|
|
1019
|
+
900,
|
|
1020
|
+
)
|
|
1021
|
+
writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
|
|
1022
|
+
expect(askCommand(h.deps, 'Which color?', { session: 'parts1' })).toBe(EXIT.ok)
|
|
1023
|
+
|
|
1024
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
|
|
1025
|
+
|
|
1026
|
+
const decision = JSON.parse(h.io.outLines.at(-1)!) as { reason: string }
|
|
1027
|
+
expect(decision.reason).toContain('in the order written')
|
|
1028
|
+
expect(decision.reason).toContain('"Use blue", then "actually teal"')
|
|
931
1029
|
})
|
|
932
1030
|
|
|
933
1031
|
it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
|
package/src/hooks.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
-
import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
|
|
14
|
+
import type { LifecycleEndState, QuestionT, ReplyView } from '@raidiant/notifai-protocol'
|
|
15
15
|
import type { ApiClient } from './client.js'
|
|
16
16
|
import {
|
|
17
17
|
loadConfig,
|
|
@@ -120,6 +120,7 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
|
|
|
120
120
|
const ORPHAN_QUEUE_CAP = 50
|
|
121
121
|
|
|
122
122
|
export interface PendingQuestion {
|
|
123
|
+
/** One-line summary: the single question's text, or the set's first. */
|
|
123
124
|
question: string
|
|
124
125
|
/**
|
|
125
126
|
* Epoch ms when `notifai ask` registered this. The grace window runs from
|
|
@@ -128,11 +129,12 @@ export interface PendingQuestion {
|
|
|
128
129
|
*/
|
|
129
130
|
asked_at?: number
|
|
130
131
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* comma survives the round trip.
|
|
132
|
+
* The full question set as `notifai ask` validated it: generated ids,
|
|
133
|
+
* texts, choices, multi flags. What actually rides the push.
|
|
134
134
|
*/
|
|
135
|
-
|
|
135
|
+
questions?: QuestionT[]
|
|
136
|
+
/** Long-form markdown context, shown in the companion's detail view. */
|
|
137
|
+
detail?: string
|
|
136
138
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
137
139
|
request_id?: string
|
|
138
140
|
collapse_key?: string
|
|
@@ -641,7 +643,10 @@ export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
|
|
|
641
643
|
interface AskResult {
|
|
642
644
|
requestId: string
|
|
643
645
|
collapseKey: string
|
|
646
|
+
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
644
647
|
reply: ReplyView | null
|
|
648
|
+
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
649
|
+
replies: ReplyView[]
|
|
645
650
|
/** The devices the question went to; retirement must not reach any other. */
|
|
646
651
|
devices: string[]
|
|
647
652
|
/** The wait ended amid network failures, so "no answer" is unproven. */
|
|
@@ -658,8 +663,11 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
|
658
663
|
/**
|
|
659
664
|
* Push a question and block for the answer.
|
|
660
665
|
*
|
|
661
|
-
* An answered question is closed
|
|
662
|
-
*
|
|
666
|
+
* An answered question is closed the moment this wait sees the answer: the
|
|
667
|
+
* first device to answer claims the question and the others are told. Until
|
|
668
|
+
* that close, a later reply is a correction — so what this returns as "the"
|
|
669
|
+
* answer is always the latest reply, with the full stream alongside for
|
|
670
|
+
* free-text answers given in parts. An unanswered question stays open,
|
|
663
671
|
* because nobody has acted on the silence yet and the next turn can still
|
|
664
672
|
* collect the answer with `notifai replies`.
|
|
665
673
|
*/
|
|
@@ -668,7 +676,8 @@ async function askAndWait(
|
|
|
668
676
|
options: {
|
|
669
677
|
title: string
|
|
670
678
|
body: string
|
|
671
|
-
|
|
679
|
+
questions: QuestionT[]
|
|
680
|
+
detail?: string | undefined
|
|
672
681
|
event: string
|
|
673
682
|
/** Which agent is asking; two of them must not look alike. */
|
|
674
683
|
session?: string | undefined
|
|
@@ -695,7 +704,8 @@ async function askAndWait(
|
|
|
695
704
|
device: answerable,
|
|
696
705
|
reply: true,
|
|
697
706
|
replyWindow: Math.max(60, options.windowSeconds),
|
|
698
|
-
|
|
707
|
+
questions: options.questions,
|
|
708
|
+
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
|
699
709
|
collapseKey,
|
|
700
710
|
})
|
|
701
711
|
if (!build.ok) return { error: build.error }
|
|
@@ -718,6 +728,7 @@ async function askAndWait(
|
|
|
718
728
|
requestId: receipt.request_id,
|
|
719
729
|
collapseKey,
|
|
720
730
|
reply: null,
|
|
731
|
+
replies: [],
|
|
721
732
|
devices: answerable,
|
|
722
733
|
degraded: false,
|
|
723
734
|
waited: false,
|
|
@@ -730,7 +741,11 @@ async function askAndWait(
|
|
|
730
741
|
return {
|
|
731
742
|
requestId: receipt.request_id,
|
|
732
743
|
collapseKey,
|
|
733
|
-
reply:
|
|
744
|
+
// The latest reply, not the first: an earlier conflicting answer was
|
|
745
|
+
// corrected by the one that followed it. The cross-device race is still
|
|
746
|
+
// first-answer-claims — that is what the close above enforces.
|
|
747
|
+
reply: result.replies.at(-1) ?? null,
|
|
748
|
+
replies: result.replies,
|
|
734
749
|
devices: answerable,
|
|
735
750
|
degraded: result.degraded,
|
|
736
751
|
waited: true,
|
|
@@ -1088,7 +1103,10 @@ const LATE_PROMPT_POLL_SECONDS = 3
|
|
|
1088
1103
|
const MAX_CONTINUATION_COUNT = 3
|
|
1089
1104
|
|
|
1090
1105
|
interface PendingPoll {
|
|
1106
|
+
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
1091
1107
|
reply: ReplyView | null
|
|
1108
|
+
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
1109
|
+
replies: ReplyView[]
|
|
1092
1110
|
degraded: boolean
|
|
1093
1111
|
failed: boolean
|
|
1094
1112
|
}
|
|
@@ -1099,34 +1117,64 @@ async function pollPendingReply(
|
|
|
1099
1117
|
pending: PendingQuestion,
|
|
1100
1118
|
timeoutSeconds: number,
|
|
1101
1119
|
): Promise<PendingPoll> {
|
|
1102
|
-
if (pending.request_id === undefined)
|
|
1120
|
+
if (pending.request_id === undefined) {
|
|
1121
|
+
return { reply: null, replies: [], degraded: false, failed: false }
|
|
1122
|
+
}
|
|
1103
1123
|
try {
|
|
1104
1124
|
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
1105
1125
|
return {
|
|
1106
|
-
reply: result.replies
|
|
1126
|
+
reply: result.replies.at(-1) ?? null,
|
|
1127
|
+
replies: result.replies,
|
|
1107
1128
|
degraded: result.degraded === true,
|
|
1108
1129
|
failed: false,
|
|
1109
1130
|
}
|
|
1110
1131
|
} catch {
|
|
1111
|
-
return { reply: null, degraded: true, failed: true }
|
|
1132
|
+
return { reply: null, replies: [], degraded: true, failed: true }
|
|
1112
1133
|
}
|
|
1113
1134
|
}
|
|
1114
1135
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1136
|
+
/** The question set this pending record pushes, however it was registered. */
|
|
1137
|
+
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
1138
|
+
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Did any question in the set offer choices? Decides how replies combine. */
|
|
1142
|
+
function pendingHasChoices(pending: PendingQuestion): boolean {
|
|
1143
|
+
return pendingQuestions(pending).some((question) => question.choices !== undefined)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* The answer as the agent should read it. For a question with choices the
|
|
1148
|
+
* latest reply IS the answer — an earlier conflicting one was corrected by
|
|
1149
|
+
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
1150
|
+
* agent in the order it was written, so it can tell expansion from
|
|
1151
|
+
* correction itself.
|
|
1152
|
+
*/
|
|
1153
|
+
function answerContext(replies: ReplyView[], hadChoices: boolean): string {
|
|
1154
|
+
const latest = replies.at(-1)
|
|
1155
|
+
if (latest === undefined) return 'Notifai — no answer was recorded.'
|
|
1156
|
+
if (replies.length === 1 || hadChoices) {
|
|
1157
|
+
return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
|
|
1158
|
+
}
|
|
1159
|
+
const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
|
|
1160
|
+
return (
|
|
1161
|
+
`Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
|
|
1162
|
+
`in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
|
|
1163
|
+
'Continue with that answer.'
|
|
1164
|
+
)
|
|
1117
1165
|
}
|
|
1118
1166
|
|
|
1119
|
-
function userPromptAnswerOutput(
|
|
1167
|
+
function userPromptAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
|
|
1120
1168
|
return JSON.stringify({
|
|
1121
1169
|
hookSpecificOutput: {
|
|
1122
1170
|
hookEventName: 'UserPromptSubmit',
|
|
1123
|
-
additionalContext: answerContext(
|
|
1171
|
+
additionalContext: answerContext(replies, hadChoices),
|
|
1124
1172
|
},
|
|
1125
1173
|
})
|
|
1126
1174
|
}
|
|
1127
1175
|
|
|
1128
|
-
function stopAnswerOutput(
|
|
1129
|
-
return JSON.stringify({ decision: 'block', reason: answerContext(
|
|
1176
|
+
function stopAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
|
|
1177
|
+
return JSON.stringify({ decision: 'block', reason: answerContext(replies, hadChoices) })
|
|
1130
1178
|
}
|
|
1131
1179
|
|
|
1132
1180
|
/**
|
|
@@ -1199,7 +1247,7 @@ export async function handleUserPromptSubmit(
|
|
|
1199
1247
|
}
|
|
1200
1248
|
await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1201
1249
|
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
|
|
1202
|
-
return { stdout: userPromptAnswerOutput(late.
|
|
1250
|
+
return { stdout: userPromptAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
|
|
1203
1251
|
}
|
|
1204
1252
|
if (late.failed || late.degraded) {
|
|
1205
1253
|
notes.push('could not check the pending question for a late answer before the prompt')
|
|
@@ -1279,7 +1327,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1279
1327
|
if (late.reply !== null) {
|
|
1280
1328
|
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
|
|
1281
1329
|
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
|
|
1282
|
-
return { stdout: stopAnswerOutput(late.
|
|
1330
|
+
return { stdout: stopAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
|
|
1283
1331
|
}
|
|
1284
1332
|
// Already live on the user's devices from an earlier Stop; asking twice for
|
|
1285
1333
|
// one question is the nagging failure this feature exists to avoid.
|
|
@@ -1352,13 +1400,20 @@ async function escalate(
|
|
|
1352
1400
|
notes.push('no idle signal on this machine; asking now rather than holding the terminal')
|
|
1353
1401
|
}
|
|
1354
1402
|
|
|
1403
|
+
const questions = pendingQuestions(pending)
|
|
1355
1404
|
const asked = await askAndWait(ctx, {
|
|
1356
1405
|
title:
|
|
1357
1406
|
ctx.config.project.value === null
|
|
1358
1407
|
? 'Question'
|
|
1359
1408
|
: `Question · ${ctx.config.project.value}`,
|
|
1360
|
-
|
|
1361
|
-
|
|
1409
|
+
// A set is answered on the expanded card; the banner leads with the first
|
|
1410
|
+
// question and says how much more is waiting behind it.
|
|
1411
|
+
body:
|
|
1412
|
+
questions.length > 1
|
|
1413
|
+
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
1414
|
+
: pending.question,
|
|
1415
|
+
questions,
|
|
1416
|
+
...(pending.detail !== undefined ? { detail: pending.detail } : {}),
|
|
1362
1417
|
event: 'agent_question',
|
|
1363
1418
|
session: sessionLabel(ctx, envelope),
|
|
1364
1419
|
// Outlives the block, and stays open on purpose: the answer is still
|
|
@@ -1426,7 +1481,7 @@ async function escalate(
|
|
|
1426
1481
|
}, asked.reply, true)
|
|
1427
1482
|
notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
|
|
1428
1483
|
return {
|
|
1429
|
-
stdout: stopAnswerOutput(asked.
|
|
1484
|
+
stdout: stopAnswerOutput(asked.replies, pendingHasChoices(pending)),
|
|
1430
1485
|
notes,
|
|
1431
1486
|
}
|
|
1432
1487
|
}
|
|
@@ -44,28 +44,21 @@ function ours(): HookConfig {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
describe('choice labels', () => {
|
|
47
|
-
it('
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
])
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('keeps commas inside a label when the flag is repeated', () => {
|
|
55
|
-
// Observed live 2026-08-02: "Yes, it worked,No, something broke" passed as
|
|
56
|
-
// one value silently became four choices, and 2-6 validation let it
|
|
57
|
-
// through because four is in range. Repeating the flag is the escape.
|
|
47
|
+
it('treats a comma as an ordinary character, never a delimiter', () => {
|
|
48
|
+
// Observed live 2026-08-02 under the old comma-split grammar:
|
|
49
|
+
// "Yes, it worked,No, something broke" passed as one value silently
|
|
50
|
+
// became four choices. The delimiter form is gone; one flag, one answer.
|
|
58
51
|
expect(parseChoices(['Yes, it worked', 'No, something broke'])).toEqual([
|
|
59
52
|
{ id: 'yes-it-worked', label: 'Yes, it worked' },
|
|
60
53
|
{ id: 'no-something-broke', label: 'No, something broke' },
|
|
61
54
|
])
|
|
62
|
-
expect(parseChoices('
|
|
55
|
+
expect(parseChoices(['Staging,Production', 'Neither'])).toEqual([
|
|
56
|
+
{ id: 'staging-production', label: 'Staging,Production' },
|
|
57
|
+
{ id: 'neither', label: 'Neither' },
|
|
58
|
+
])
|
|
63
59
|
})
|
|
64
60
|
|
|
65
61
|
it('rejects a set that cannot become a valid closed question', () => {
|
|
66
|
-
// A pipe-separated string is one label, not two — the shape that reached
|
|
67
|
-
// the hook and failed there instead of at registration.
|
|
68
|
-
expect(parseChoices('Yes|No')).toBe('invalid')
|
|
69
62
|
expect(parseChoices(['Only one'])).toBe('invalid')
|
|
70
63
|
expect(parseChoices(['A', 'a'])).toBe('invalid')
|
|
71
64
|
expect(parseChoices(['1', '2', '3', '4', '5', '6', '7'])).toBe('invalid')
|
package/src/main.ts
CHANGED
|
@@ -195,9 +195,10 @@ const send = program
|
|
|
195
195
|
.option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
|
|
196
196
|
.option(
|
|
197
197
|
'--reply-choice <label>',
|
|
198
|
-
'with --reply, ask a closed question
|
|
198
|
+
'with --reply, ask a closed question; repeat the flag once per answer (2-6)',
|
|
199
199
|
(v: string, all: string[]) => [...all, v], [],
|
|
200
200
|
)
|
|
201
|
+
.option('--reply-multi', 'with --reply-choice, several answers may be selected')
|
|
201
202
|
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
202
203
|
// a message pointing at `ask` instead of "unknown option".
|
|
203
204
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
@@ -264,19 +265,55 @@ program
|
|
|
264
265
|
})
|
|
265
266
|
|
|
266
267
|
program
|
|
267
|
-
.command('ask
|
|
268
|
+
.command('ask [question]')
|
|
268
269
|
.description('Register a question for the turn-end hook to route under your presence settings')
|
|
269
270
|
.option(
|
|
270
271
|
'--choice <label>',
|
|
271
|
-
'answers to offer instead of free text
|
|
272
|
+
'answers to offer instead of free text; repeat the flag once per answer (2-6)',
|
|
272
273
|
(v: string, all: string[]) => [...all, v], [],
|
|
273
274
|
)
|
|
275
|
+
.option('--multi', 'with --choice, several answers may be selected')
|
|
276
|
+
.option('--detail <markdown>', 'long-form context shown only in the app, never on the banner')
|
|
277
|
+
.option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
|
|
278
|
+
.option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
|
|
274
279
|
.option('--session <id>', 'session id (default: the session working in this directory, else $NOTIFAI_SESSION)')
|
|
275
|
-
.action((question: string, opts: {
|
|
280
|
+
.action((question: string | undefined, opts: {
|
|
281
|
+
choice?: string[]
|
|
282
|
+
multi?: boolean
|
|
283
|
+
detail?: string
|
|
284
|
+
detailFile?: string
|
|
285
|
+
form?: string
|
|
286
|
+
session?: string
|
|
287
|
+
}) => {
|
|
288
|
+
let detail = opts.detail
|
|
289
|
+
if (typeof opts.detailFile === 'string') {
|
|
290
|
+
if (detail !== undefined) {
|
|
291
|
+
deps.io.err('Pass either --detail or --detail-file, not both.')
|
|
292
|
+
process.exit(2)
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
detail = readFileSync(opts.detailFile === '-' ? 0 : opts.detailFile, 'utf8')
|
|
296
|
+
} catch (err) {
|
|
297
|
+
deps.io.err(`Could not read ${opts.detailFile}: ${String(err)}`)
|
|
298
|
+
process.exit(2)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let form: string | undefined
|
|
302
|
+
if (typeof opts.form === 'string') {
|
|
303
|
+
try {
|
|
304
|
+
form = readFileSync(opts.form === '-' ? 0 : opts.form, 'utf8')
|
|
305
|
+
} catch (err) {
|
|
306
|
+
deps.io.err(`Could not read ${opts.form}: ${String(err)}`)
|
|
307
|
+
process.exit(2)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
276
310
|
// commander's collector defaults to []; an empty list means "not asked".
|
|
277
311
|
const flags: Parameters<typeof askCommand>[2] = {
|
|
278
312
|
...(opts.session !== undefined ? { session: opts.session } : {}),
|
|
279
313
|
...(opts.choice?.length ? { choice: opts.choice } : {}),
|
|
314
|
+
...(opts.multi ? { multi: true } : {}),
|
|
315
|
+
...(detail !== undefined ? { detail } : {}),
|
|
316
|
+
...(form !== undefined ? { form } : {}),
|
|
280
317
|
}
|
|
281
318
|
process.exit(askCommand(deps, question, flags))
|
|
282
319
|
})
|
package/src/send.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
COLLAPSE_KEY_MAX_BYTES,
|
|
4
4
|
INTERRUPTION_LEVELS,
|
|
5
5
|
NOTIFICATION_SCHEMA_VERSION,
|
|
6
|
+
QUESTION_TEXT_MAX_LENGTH,
|
|
6
7
|
type IosOptionsT,
|
|
7
8
|
type LifecycleT,
|
|
8
9
|
type MacosOptionsT,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
NOTIFICATION_KINDS,
|
|
11
12
|
PLATFORMS,
|
|
12
13
|
type Platform,
|
|
14
|
+
type QuestionT,
|
|
13
15
|
type SubmissionReceipt,
|
|
14
16
|
} from '@raidiant/notifai-protocol'
|
|
15
17
|
import type { CliConfig } from './config.js'
|
|
@@ -41,10 +43,17 @@ export interface SendFlags {
|
|
|
41
43
|
/** How long the server accepts a reply after submission. */
|
|
42
44
|
replyWindow?: number
|
|
43
45
|
/**
|
|
44
|
-
* Answer labels; turns the reply into a closed question. One
|
|
45
|
-
*
|
|
46
|
+
* Answer labels; turns the reply into a closed question. One flag occurrence
|
|
47
|
+
* per label, always — a label is a label, whatever characters it contains.
|
|
46
48
|
*/
|
|
47
|
-
replyChoice?: string
|
|
49
|
+
replyChoice?: string[]
|
|
50
|
+
/** The user may select several of the offered answers. */
|
|
51
|
+
replyMulti?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* A full question set, overriding the single question the flags describe.
|
|
54
|
+
* Not a user-facing flag: `ask --form` and the hooks build it.
|
|
55
|
+
*/
|
|
56
|
+
questions?: QuestionT[]
|
|
48
57
|
/** Platform whose optional notification fields these flags configure. */
|
|
49
58
|
platform?: string
|
|
50
59
|
/**
|
|
@@ -149,6 +158,24 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
149
158
|
error: CHOICE_USAGE,
|
|
150
159
|
}
|
|
151
160
|
}
|
|
161
|
+
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
162
|
+
return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
|
|
163
|
+
}
|
|
164
|
+
// The banner body doubles as the question text on a single-question send, so
|
|
165
|
+
// it inherits the question's readable-in-full bound; context beyond that
|
|
166
|
+
// belongs in --detail, which never rides the banner.
|
|
167
|
+
if (
|
|
168
|
+
flags.reply &&
|
|
169
|
+
flags.questions === undefined &&
|
|
170
|
+
flags.body.length > QUESTION_TEXT_MAX_LENGTH
|
|
171
|
+
) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error:
|
|
175
|
+
`A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
|
|
176
|
+
'characters and put the longer context in --detail.',
|
|
177
|
+
}
|
|
178
|
+
}
|
|
152
179
|
|
|
153
180
|
const draft: NotificationDraftT = {
|
|
154
181
|
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
@@ -174,7 +201,14 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
174
201
|
? {
|
|
175
202
|
reply: {
|
|
176
203
|
expires_in_seconds: flags.replyWindow ?? 3600,
|
|
177
|
-
|
|
204
|
+
questions: flags.questions ?? [
|
|
205
|
+
{
|
|
206
|
+
id: 'q1',
|
|
207
|
+
text: flags.body,
|
|
208
|
+
...(choices !== null ? { choices } : {}),
|
|
209
|
+
...(flags.replyMulti ? { multi: true } : {}),
|
|
210
|
+
},
|
|
211
|
+
],
|
|
178
212
|
},
|
|
179
213
|
}
|
|
180
214
|
: {}),
|
|
@@ -229,23 +263,19 @@ export function receiptExitCode(receipt: SubmissionReceipt): number {
|
|
|
229
263
|
}
|
|
230
264
|
|
|
231
265
|
/**
|
|
232
|
-
*
|
|
266
|
+
* One flag occurrence, one answer: `--choice Staging --choice Production` ->
|
|
267
|
+
* `[{id:'staging',label:'Staging'}, ...]`.
|
|
233
268
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* agent meant two.
|
|
269
|
+
* Commas are ordinary characters. The old single-value comma split produced
|
|
270
|
+
* buttons the agent never intended ("Nothing, I'll review" became two answers)
|
|
271
|
+
* and nothing downstream could detect it, so the delimiter form is gone
|
|
272
|
+
* rather than guarded: a label is a label.
|
|
239
273
|
*/
|
|
240
274
|
export function parseChoices(
|
|
241
|
-
value: string
|
|
275
|
+
value: string[] | undefined,
|
|
242
276
|
): { id: string; label: string }[] | null | 'invalid' {
|
|
243
|
-
if (value === undefined) return null
|
|
244
|
-
const
|
|
245
|
-
if (raw.length === 0) return null
|
|
246
|
-
const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
|
|
247
|
-
.map((part) => part.trim())
|
|
248
|
-
.filter((part) => part.length > 0)
|
|
277
|
+
if (value === undefined || value.length === 0) return null
|
|
278
|
+
const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
|
|
249
279
|
if (labels.length < 2 || labels.length > 6) return 'invalid'
|
|
250
280
|
const choices = labels.map((label) => ({ id: slugify(label), label }))
|
|
251
281
|
if (choices.some((choice) => choice.id === '')) return 'invalid'
|
|
@@ -255,38 +285,10 @@ export function parseChoices(
|
|
|
255
285
|
|
|
256
286
|
/** Message shared by every surface that accepts choice labels. */
|
|
257
287
|
export const CHOICE_USAGE =
|
|
258
|
-
'
|
|
259
|
-
'
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Catches the split that looked like it worked.
|
|
263
|
-
*
|
|
264
|
-
* `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
|
|
265
|
-
* buttons rather than three, because the third label contained a comma. Nothing
|
|
266
|
-
* downstream can detect it — the labels are all well-formed — and the CLI
|
|
267
|
-
* cheerfully echoed the wrong list back, so the user saw buttons the agent
|
|
268
|
-
* never intended.
|
|
269
|
-
*
|
|
270
|
-
* The tell is a comma followed by a space. A delimiter list is written
|
|
271
|
-
* "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
|
|
272
|
-
* will occasionally be wrong, which is exactly why this warns and names the
|
|
273
|
-
* escape hatch rather than rejecting: the split may well have been intended.
|
|
274
|
-
*/
|
|
275
|
-
export function ambiguousChoiceSplit(value: string | string[] | undefined): string | null {
|
|
276
|
-
if (value === undefined || Array.isArray(value)) return null
|
|
277
|
-
if (!/,\s/.test(value)) return null
|
|
278
|
-
const parts = value
|
|
279
|
-
.split(',')
|
|
280
|
-
.map((part) => part.trim())
|
|
281
|
-
.filter((part) => part.length > 0)
|
|
282
|
-
return (
|
|
283
|
-
`Heads up: "${value}" was split on commas into ${parts.length} answers ` +
|
|
284
|
-
`(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
|
|
285
|
-
'pass --choice once per answer instead.'
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
+
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
289
|
+
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
288
290
|
|
|
289
|
-
function slugify(label: string): string {
|
|
291
|
+
export function slugify(label: string): string {
|
|
290
292
|
return label
|
|
291
293
|
.toLowerCase()
|
|
292
294
|
.replace(/[^a-z0-9]+/g, '-')
|