@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/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
- choice_id: 'allow',
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: 'Yes|No', session: 'a1' })).toBe(EXIT.usage)
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 labels verbatim, commas and all', () => {
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?.choices).toEqual(['Yes, ship it', 'No, hold'])
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
- * Canonical answer labels, already split and validated by `notifai ask`.
132
- * Stored as a list rather than a comma-joined string so a label containing a
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
- choices?: string[]
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 immediately: the first answer wins, and the
662
- * other devices must stop offering to change it. An unanswered one stays open,
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
- choices?: string[]
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
- ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
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: result.replies[0] ?? null,
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) return { reply: null, degraded: false, failed: false }
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[0] ?? null,
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
- function answerContext(reply: ReplyView): string {
1116
- return `Notifai — the user answered from ${reply.device_name}: "${reply.text}". Continue with that answer.`
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(reply: ReplyView): string {
1167
+ function userPromptAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
1120
1168
  return JSON.stringify({
1121
1169
  hookSpecificOutput: {
1122
1170
  hookEventName: 'UserPromptSubmit',
1123
- additionalContext: answerContext(reply),
1171
+ additionalContext: answerContext(replies, hadChoices),
1124
1172
  },
1125
1173
  })
1126
1174
  }
1127
1175
 
1128
- function stopAnswerOutput(reply: ReplyView): string {
1129
- return JSON.stringify({ decision: 'block', reason: answerContext(reply) })
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.reply), notes }
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.reply), notes }
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
- body: pending.question,
1361
- ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
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.reply),
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('splits a single value on commas for the one-liner case', () => {
48
- expect(parseChoices('Staging,Production')).toEqual([
49
- { id: 'staging', label: 'Staging' },
50
- { id: 'production', label: 'Production' },
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('Yes, it worked,No, something broke')).toHaveLength(4)
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 (2-6 answers); one value splits on commas, or repeat the flag',
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 <question>')
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 (2-6); one value splits on commas, or repeat the flag',
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: { choice?: string[]; session?: string }) => {
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 value splits on
45
- * commas; repeating the flag passes each label through verbatim.
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 | 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
- ...(choices !== null ? { kind: 'choice' as const, choices } : {}),
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
- * `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
266
+ * One flag occurrence, one answer: `--choice Staging --choice Production` ->
267
+ * `[{id:'staging',label:'Staging'}, ...]`.
233
268
  *
234
- * A single occurrence splits on commas, which keeps the common one-liner
235
- * short. Repeating the flag is the escape hatch, and the only way to write a
236
- * label that itself contains a comma: passing `"Yes, ship it,No"` as one value
237
- * silently yields three choices, and nothing downstream can detect that the
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 | string[] | undefined,
275
+ value: string[] | undefined,
242
276
  ): { id: string; label: string }[] | null | 'invalid' {
243
- if (value === undefined) return null
244
- const raw = Array.isArray(value) ? value : [value]
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
- 'Choices must be 2-6 non-empty labels, unique once slugified. One value splits on ' +
259
- 'commas ("Staging,Production"); repeat the flag when a label contains a comma.'
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, '-')