@raidiant/notifai 0.1.8 → 0.2.1

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.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,
@@ -57,19 +57,26 @@ export interface HookEnvelope {
57
57
  export interface SessionState {
58
58
  /** Epoch ms of the user's last prompt in this session — our presence signal. */
59
59
  last_prompt_at?: number
60
- /** Question registered by `notifai ask`, awaiting the turn to end. */
61
- pending?: PendingQuestion
60
+ /**
61
+ * Questions registered by `notifai ask`, in registration order, each
62
+ * awaiting escalation or its answer. A list, deliberately: registering a
63
+ * question never ends an earlier one. Superseding is reply semantics — a
64
+ * later reply corrects an earlier reply to the same question — never
65
+ * question semantics; the single-slot model silently discarded a live
66
+ * question the moment a second was registered (2026-08-09).
67
+ */
68
+ pending?: PendingQuestion[]
62
69
  /**
63
70
  * Questions that have been delivered to the user's devices and are now dead,
64
71
  * but whose retirement has not been confirmed yet.
65
72
  *
66
73
  * A retirement needs a network call and the moment we learn a question is
67
- * dead is not always a moment we can make one — `notifai ask` supersedes the
68
- * previous question from a bare shell command, and the machine may be
69
- * offline. Dropping the ids there is how a delivered question becomes
70
- * permanently unretirable, so they are parked here instead and every later
71
- * hook with a client drains them. Retirement is idempotent, so a duplicate
72
- * attempt costs nothing and a missed one costs a stale notification for ever.
74
+ * dead is not always a moment we can make one — the user's return to the
75
+ * terminal is observed by a bare hook, and the machine may be offline.
76
+ * Dropping the ids there is how a delivered question becomes permanently
77
+ * unretirable, so they are parked here instead and every later hook with a
78
+ * client drains them. Retirement is idempotent, so a duplicate attempt
79
+ * costs nothing and a missed one costs a stale notification for ever.
73
80
  */
74
81
  retiring?: RetiringQuestion[]
75
82
  /** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
@@ -120,6 +127,7 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
120
127
  const ORPHAN_QUEUE_CAP = 50
121
128
 
122
129
  export interface PendingQuestion {
130
+ /** One-line summary: the single question's text, or the set's first. */
123
131
  question: string
124
132
  /**
125
133
  * Epoch ms when `notifai ask` registered this. The grace window runs from
@@ -128,11 +136,12 @@ export interface PendingQuestion {
128
136
  */
129
137
  asked_at?: number
130
138
  /**
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.
139
+ * The full question set as `notifai ask` validated it: generated ids,
140
+ * texts, choices, multi flags. What actually rides the push.
134
141
  */
135
- choices?: string[]
142
+ questions?: QuestionT[]
143
+ /** Long-form markdown context, shown in the companion's detail view. */
144
+ detail?: string
136
145
  /** Set once the question has actually been pushed, so it can be retired. */
137
146
  request_id?: string
138
147
  collapse_key?: string
@@ -638,18 +647,11 @@ export interface HookContext {
638
647
 
639
648
  export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
640
649
 
641
- interface AskResult {
650
+ interface SubmittedQuestion {
642
651
  requestId: string
643
652
  collapseKey: string
644
- reply: ReplyView | null
645
653
  /** The devices the question went to; retirement must not reach any other. */
646
654
  devices: string[]
647
- /** The wait ended amid network failures, so "no answer" is unproven. */
648
- degraded: boolean
649
- /** False when the harness cannot consume a Stop answer and polling was skipped. */
650
- waited: boolean
651
- /** The user returned to this machine after the question was pushed. */
652
- userReturned: boolean
653
655
  }
654
656
 
655
657
  /** Re-check local presence at this cadence while a pushed question is waiting. */
@@ -658,28 +660,33 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
658
660
  /**
659
661
  * Push a question and block for the answer.
660
662
  *
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,
663
+ * An answered question is closed the moment this wait sees the answer: the
664
+ * first device to answer claims the question and the others are told. Until
665
+ * that close, a later reply is a correction — so what this returns as "the"
666
+ * answer is always the latest reply, with the full stream alongside for
667
+ * free-text answers given in parts. An unanswered question stays open,
663
668
  * because nobody has acted on the silence yet and the next turn can still
664
669
  * collect the answer with `notifai replies`.
665
670
  */
666
- async function askAndWait(
671
+ /**
672
+ * Put one registered question on the user's devices. Submission only — the
673
+ * wait happens once, across every live question, in the caller.
674
+ */
675
+ async function submitQuestion(
667
676
  ctx: HookContext,
668
677
  options: {
669
678
  title: string
670
679
  body: string
671
- choices?: string[]
680
+ questions: QuestionT[]
681
+ detail?: string | undefined
672
682
  event: string
673
683
  /** Which agent is asking; two of them must not look alike. */
674
684
  session?: string | undefined
675
685
  /** How long the server keeps accepting an answer. */
676
686
  windowSeconds: number
677
- /** Called once the question is live, before the block begins. */
678
- onSubmitted?: (live: { requestId: string; collapseKey: string; devices: string[] }) => void
679
687
  },
680
- ): Promise<AskResult | { error: string }> {
688
+ ): Promise<SubmittedQuestion | { error: string }> {
681
689
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
682
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
683
690
  // A draft carrying `reply` is rejected outright if it targets a device that
684
691
  // cannot answer, so resolve the healthy companion platforms explicitly.
685
692
  const answerable = await answerableDevices(ctx)
@@ -695,7 +702,8 @@ async function askAndWait(
695
702
  device: answerable,
696
703
  reply: true,
697
704
  replyWindow: Math.max(60, options.windowSeconds),
698
- ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
705
+ questions: options.questions,
706
+ ...(options.detail !== undefined ? { detail: options.detail } : {}),
699
707
  collapseKey,
700
708
  })
701
709
  if (!build.ok) return { error: build.error }
@@ -704,45 +712,20 @@ async function askAndWait(
704
712
  { idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
705
713
  0,
706
714
  )
707
- // Record what is now live on the user's devices BEFORE blocking. If we only
708
- // learned these ids after the wait, a question that timed out would leave no
709
- // trace, and the user returning to the terminal could never retire it — the
710
- // notification would stay answerable for an hour with nobody listening.
711
- options.onSubmitted?.({
712
- requestId: receipt.request_id,
713
- collapseKey,
714
- devices: answerable,
715
- })
716
- if (ctx.harness === 'opencode') {
717
- return {
718
- requestId: receipt.request_id,
719
- collapseKey,
720
- reply: null,
721
- devices: answerable,
722
- degraded: false,
723
- waited: false,
724
- userReturned: false,
725
- }
726
- }
727
-
728
- const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds)
729
- if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
730
- return {
731
- requestId: receipt.request_id,
732
- collapseKey,
733
- reply: result.replies[0] ?? null,
734
- devices: answerable,
735
- degraded: result.degraded,
736
- waited: true,
737
- userReturned: result.userReturned,
738
- }
715
+ return { requestId: receipt.request_id, collapseKey, devices: answerable }
739
716
  }
740
717
 
741
- async function waitForReplyWhileAway(
718
+ /**
719
+ * One blocking wait across every live question. Each round polls all of them
720
+ * concurrently, so several questions cost the same wall clock as one; the
721
+ * first round that finds any reply returns everything found in that round, and
722
+ * whatever was not answered stays registered.
723
+ */
724
+ async function waitForAnyReplyWhileAway(
742
725
  ctx: HookContext,
743
- requestId: string,
726
+ requestIds: string[],
744
727
  timeoutSeconds: number,
745
- ): Promise<{ replies: ReplyView[]; degraded: boolean; userReturned: boolean }> {
728
+ ): Promise<{ byRequest: Map<string, ReplyView[]>; degraded: boolean; userReturned: boolean }> {
746
729
  const deadline = ctx.now() + timeoutSeconds * 1000
747
730
  let degraded = false
748
731
  let firstPoll = true
@@ -751,24 +734,34 @@ async function waitForReplyWhileAway(
751
734
  if (ctx.config.require_idle.value) {
752
735
  const idle = ctx.idleSeconds()
753
736
  if (idle !== null && idle < ctx.config.away_after_seconds.value) {
754
- return { replies: [], degraded, userReturned: true }
737
+ return { byRequest: new Map(), degraded, userReturned: true }
755
738
  }
756
739
  }
757
740
 
758
741
  const remainingMs = Math.max(0, deadline - ctx.now())
759
742
  if (!firstPoll && remainingMs === 0) {
760
- return { replies: [], degraded, userReturned: false }
743
+ return { byRequest: new Map(), degraded, userReturned: false }
761
744
  }
762
745
  firstPoll = false
763
746
  const pollSeconds = Math.min(
764
747
  REPLY_PRESENCE_POLL_SECONDS,
765
748
  Math.max(0, Math.ceil(remainingMs / 1000)),
766
749
  )
767
- const result = await ctx.waitForFirstReply(requestId, pollSeconds)
768
- degraded ||= result.degraded === true
769
- if (result.replies.length > 0) {
770
- return { replies: result.replies, degraded, userReturned: false }
750
+ const results = await Promise.all(
751
+ requestIds.map(async (requestId) => {
752
+ try {
753
+ return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
754
+ } catch {
755
+ return { requestId, replies: [] as ReplyView[], degraded: true }
756
+ }
757
+ }),
758
+ )
759
+ const byRequest = new Map<string, ReplyView[]>()
760
+ for (const result of results) {
761
+ degraded ||= result.degraded === true
762
+ if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
771
763
  }
764
+ if (byRequest.size > 0) return { byRequest, degraded, userReturned: false }
772
765
  }
773
766
  }
774
767
 
@@ -1088,7 +1081,10 @@ const LATE_PROMPT_POLL_SECONDS = 3
1088
1081
  const MAX_CONTINUATION_COUNT = 3
1089
1082
 
1090
1083
  interface PendingPoll {
1084
+ /** The answer to act on: the latest reply, because a later one corrects. */
1091
1085
  reply: ReplyView | null
1086
+ /** Every reply in arrival order, for free-text answers given in parts. */
1087
+ replies: ReplyView[]
1092
1088
  degraded: boolean
1093
1089
  failed: boolean
1094
1090
  }
@@ -1099,63 +1095,161 @@ async function pollPendingReply(
1099
1095
  pending: PendingQuestion,
1100
1096
  timeoutSeconds: number,
1101
1097
  ): Promise<PendingPoll> {
1102
- if (pending.request_id === undefined) return { reply: null, degraded: false, failed: false }
1098
+ if (pending.request_id === undefined) {
1099
+ return { reply: null, replies: [], degraded: false, failed: false }
1100
+ }
1103
1101
  try {
1104
1102
  const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
1105
1103
  return {
1106
- reply: result.replies[0] ?? null,
1104
+ reply: result.replies.at(-1) ?? null,
1105
+ replies: result.replies,
1107
1106
  degraded: result.degraded === true,
1108
1107
  failed: false,
1109
1108
  }
1110
1109
  } catch {
1111
- return { reply: null, degraded: true, failed: true }
1110
+ return { reply: null, replies: [], degraded: true, failed: true }
1112
1111
  }
1113
1112
  }
1114
1113
 
1115
- function answerContext(reply: ReplyView): string {
1116
- return `Notifai — the user answered from ${reply.device_name}: "${reply.text}". Continue with that answer.`
1114
+ /** One answered registered question, with everything the agent needs to read it. */
1115
+ interface AnsweredPending {
1116
+ pending: PendingQuestion
1117
+ reply: ReplyView
1118
+ replies: ReplyView[]
1119
+ }
1120
+
1121
+ /**
1122
+ * One bounded recovery poll across every delivered question, concurrently —
1123
+ * N questions must not multiply the hook's latency budget by N.
1124
+ */
1125
+ async function pollPendingReplies(
1126
+ ctx: HookContext,
1127
+ live: PendingQuestion[],
1128
+ timeoutSeconds: number,
1129
+ ): Promise<{ answered: AnsweredPending[]; troubled: boolean }> {
1130
+ const polls = await Promise.all(
1131
+ live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
1132
+ )
1133
+ const answered: AnsweredPending[] = []
1134
+ let troubled = false
1135
+ for (const [index, poll] of polls.entries()) {
1136
+ if (poll.reply !== null) {
1137
+ answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
1138
+ }
1139
+ if (poll.failed || poll.degraded) troubled = true
1140
+ }
1141
+ return { answered, troubled }
1142
+ }
1143
+
1144
+ /** The registered-question queue. Anything but the current shape reads as empty. */
1145
+ function pendingList(state: SessionState): PendingQuestion[] {
1146
+ return Array.isArray(state.pending) ? state.pending : []
1147
+ }
1148
+
1149
+ /** Registration identity — the convention every racing writer compares by. */
1150
+ function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1151
+ return a.question === b.question && a.asked_at === b.asked_at
1152
+ }
1153
+
1154
+ /** The question set this pending record pushes, however it was registered. */
1155
+ function pendingQuestions(pending: PendingQuestion): QuestionT[] {
1156
+ return pending.questions ?? [{ id: 'q1', text: pending.question }]
1157
+ }
1158
+
1159
+ /** Did any question in the set offer choices? Decides how replies combine. */
1160
+ function pendingHasChoices(pending: PendingQuestion): boolean {
1161
+ return pendingQuestions(pending).some((question) => question.choices !== undefined)
1162
+ }
1163
+
1164
+ /**
1165
+ * The answer as the agent should read it. For a question with choices the
1166
+ * latest reply IS the answer — an earlier conflicting one was corrected by
1167
+ * it. Free-text answers can arrive in parts, and every part reaches the
1168
+ * agent in the order it was written, so it can tell expansion from
1169
+ * correction itself.
1170
+ */
1171
+ function answerContext(replies: ReplyView[], hadChoices: boolean): string {
1172
+ const latest = replies.at(-1)
1173
+ if (latest === undefined) return 'Notifai — no answer was recorded.'
1174
+ if (replies.length === 1 || hadChoices) {
1175
+ return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
1176
+ }
1177
+ const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
1178
+ return (
1179
+ `Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
1180
+ `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
1181
+ 'Continue with that answer.'
1182
+ )
1183
+ }
1184
+
1185
+ /**
1186
+ * Every answer that has arrived, as one message. Several registered questions
1187
+ * may resolve in one hook pass; the agent reads them together, each answer
1188
+ * tied to the question that asked it, with a truthful note about anything
1189
+ * still waiting.
1190
+ */
1191
+ function answersContext(answered: AnsweredPending[], remaining: number): string {
1192
+ const tail =
1193
+ remaining > 0
1194
+ ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1195
+ : ''
1196
+ if (answered.length === 1) {
1197
+ const only = answered[0]!
1198
+ return answerContext(only.replies, pendingHasChoices(only.pending)) + tail
1199
+ }
1200
+ const lines = answered.map(({ pending, replies }) => {
1201
+ const latest = replies.at(-1)!
1202
+ const answer =
1203
+ replies.length === 1 || pendingHasChoices(pending)
1204
+ ? `"${latest.text}"`
1205
+ : `${replies.map((reply) => `"${reply.text}"`).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
1206
+ return `- "${pending.question}" → ${answer} (from ${latest.device_name})`
1207
+ })
1208
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`
1117
1209
  }
1118
1210
 
1119
- function userPromptAnswerOutput(reply: ReplyView): string {
1211
+ function userPromptAnswerOutput(context: string): string {
1120
1212
  return JSON.stringify({
1121
1213
  hookSpecificOutput: {
1122
1214
  hookEventName: 'UserPromptSubmit',
1123
- additionalContext: answerContext(reply),
1215
+ additionalContext: context,
1124
1216
  },
1125
1217
  })
1126
1218
  }
1127
1219
 
1128
- function stopAnswerOutput(reply: ReplyView): string {
1129
- return JSON.stringify({ decision: 'block', reason: answerContext(reply) })
1220
+ function stopAnswerOutput(context: string): string {
1221
+ return JSON.stringify({ decision: 'block', reason: context })
1130
1222
  }
1131
1223
 
1132
1224
  /**
1133
- * Close, truthfully retire, and forget one answered pending question without
1134
- * dropping any other retirement debt. Stop answers also open one bounded
1135
- * continuation generation; UserPromptSubmit answers ride the user's new turn.
1225
+ * Close, truthfully retire, and forget every answered pending question in one
1226
+ * state write, without dropping any other retirement debt or any question
1227
+ * still waiting. Stop answers also open one bounded continuation generation;
1228
+ * UserPromptSubmit answers ride the user's new turn.
1136
1229
  */
1137
- async function finishPendingAnswer(
1230
+ async function finishAnsweredPendings(
1138
1231
  ctx: HookContext,
1139
1232
  envelope: HookEnvelope,
1140
1233
  sessionId: string,
1141
- pending: PendingQuestion,
1142
- reply: ReplyView,
1234
+ answered: AnsweredPending[],
1143
1235
  continuation: boolean,
1144
1236
  ): Promise<void> {
1145
- const retirement = retiringQuestion(pending, 'answered')
1237
+ const retirements = answered
1238
+ .map(({ pending }) => retiringQuestion(pending, 'answered'))
1239
+ .filter((entry): entry is RetiringQuestion => entry !== null)
1146
1240
  updateSessionState(sessionId, ctx.env, (current) => {
1147
1241
  const retiring = [...(current.retiring ?? [])]
1148
- if (retirement !== null) {
1242
+ for (const retirement of retirements) {
1149
1243
  const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
1150
1244
  if (existing < 0) retiring.push(retirement)
1151
1245
  else retiring[existing] = retirement
1152
1246
  }
1153
- const samePending =
1154
- current.pending?.question === pending.question &&
1155
- current.pending?.asked_at === pending.asked_at &&
1156
- current.pending?.request_id === pending.request_id
1157
- const next = { ...current, retiring }
1158
- if (samePending) delete next.pending
1247
+ const remaining = pendingList(current).filter(
1248
+ (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1249
+ )
1250
+ const next: SessionState = { ...current, retiring }
1251
+ if (remaining.length > 0) next.pending = remaining
1252
+ else delete next.pending
1159
1253
  if (continuation) {
1160
1254
  next.continuation = {
1161
1255
  answered_at: ctx.now(),
@@ -1189,20 +1283,19 @@ export async function handleUserPromptSubmit(
1189
1283
  if (!sessionId) return { notes }
1190
1284
 
1191
1285
  const state = readSessionState(sessionId, ctx.env)
1192
- const pending = state.pending
1193
- if (pending?.request_id !== undefined) {
1194
- const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS)
1195
- if (late.reply !== null) {
1196
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false)
1197
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1198
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1286
+ const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
1287
+ let lateAnswers: AnsweredPending[] = []
1288
+ if (live.length > 0) {
1289
+ const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS)
1290
+ lateAnswers = answered
1291
+ if (answered.length > 0) {
1292
+ await finishAnsweredPendings(ctx, envelope, sessionId, answered, false)
1293
+ for (const { reply } of answered) {
1294
+ notes.push(`late answer from ${reply.device_name}: ${reply.text}`)
1199
1295
  }
1200
- await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1201
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1202
- return { stdout: userPromptAnswerOutput(late.reply), notes }
1203
1296
  }
1204
- if (late.failed || late.degraded) {
1205
- notes.push('could not check the pending question for a late answer before the prompt')
1297
+ if (troubled) {
1298
+ notes.push('could not check every pending question for a late answer before the prompt')
1206
1299
  }
1207
1300
  }
1208
1301
  // Park before dropping `pending`. If the process dies between these writes,
@@ -1210,11 +1303,11 @@ export async function handleUserPromptSubmit(
1210
1303
  // the gap after erasing the only request/collapse/device identifiers.
1211
1304
  updateSessionState(sessionId, ctx.env, (current) => {
1212
1305
  const retiring = [...(current.retiring ?? [])]
1213
- if (current.pending !== undefined) {
1214
- const retirement = retiringQuestion(current.pending, 'answered_elsewhere')
1306
+ for (const entry of pendingList(current)) {
1307
+ const retirement = retiringQuestion(entry, 'answered_elsewhere')
1215
1308
  if (
1216
1309
  retirement !== null &&
1217
- !retiring.some((entry) => entry.request_id === retirement.request_id)
1310
+ !retiring.some((parked) => parked.request_id === retirement.request_id)
1218
1311
  ) {
1219
1312
  retiring.push(retirement)
1220
1313
  }
@@ -1234,6 +1327,11 @@ export async function handleUserPromptSubmit(
1234
1327
  if (swept.length > 0) {
1235
1328
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1236
1329
  }
1330
+ if (lateAnswers.length > 0) {
1331
+ // Whatever the user did not answer was just retired above — they are at
1332
+ // the keyboard now, so nothing is "still waiting" from their side.
1333
+ return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes }
1334
+ }
1237
1335
  return { notes }
1238
1336
  }
1239
1337
 
@@ -1272,23 +1370,38 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1272
1370
  }
1273
1371
 
1274
1372
  const state = readSessionState(sessionId, ctx.env)
1275
- const pending = state.pending
1276
- if (!pending) return { notes }
1277
- if (pending.request_id !== undefined) {
1278
- const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS)
1279
- if (late.reply !== null) {
1280
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
1281
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1282
- return { stdout: stopAnswerOutput(late.reply), notes }
1373
+ const pending = pendingList(state)
1374
+ if (pending.length === 0) return { notes }
1375
+ const live = pending.filter((entry) => entry.request_id !== undefined)
1376
+ const unasked = pending.filter((entry) => entry.request_id === undefined)
1377
+
1378
+ if (live.length > 0) {
1379
+ const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS)
1380
+ if (answered.length > 0) {
1381
+ await finishAnsweredPendings(ctx, envelope, sessionId, answered, true)
1382
+ for (const { reply } of answered) {
1383
+ notes.push(`late answer from ${reply.device_name}: ${reply.text}`)
1384
+ }
1385
+ // Anything still unasked rides the next Stop: the agent is being resumed
1386
+ // with answers right now, and may not even need the rest afterwards.
1387
+ return {
1388
+ stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
1389
+ notes,
1390
+ }
1283
1391
  }
1284
- // Already live on the user's devices from an earlier Stop; asking twice for
1285
- // one question is the nagging failure this feature exists to avoid.
1286
- notes.push(
1287
- late.failed || late.degraded
1288
- ? `already asked (${pending.request_id}); could not check whether its answer arrived`
1289
- : `already asked (${pending.request_id}); waiting for that answer`,
1290
- )
1291
- return { notes }
1392
+ if (unasked.length === 0) {
1393
+ // Already live on the user's devices from an earlier Stop; asking twice
1394
+ // for one question is the nagging failure this feature exists to avoid.
1395
+ const ids = live.map((entry) => entry.request_id).join(', ')
1396
+ notes.push(
1397
+ troubled
1398
+ ? `already asked (${ids}); could not check whether ${live.length === 1 ? 'its answer' : 'their answers'} arrived`
1399
+ : `already asked (${ids}); waiting for ${live.length === 1 ? 'that answer' : 'those answers'}`,
1400
+ )
1401
+ return { notes }
1402
+ }
1403
+ // Questions registered after the earlier push still owe the user their
1404
+ // notification; fall through and escalate just those.
1292
1405
  }
1293
1406
 
1294
1407
  // A Stop answer may immediately produce a legitimate follow-up question.
@@ -1298,8 +1411,9 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1298
1411
  const continuation = state.continuation
1299
1412
  const isNew =
1300
1413
  continuation !== undefined &&
1301
- pending.asked_at !== undefined &&
1302
- pending.asked_at > continuation.answered_at
1414
+ unasked.some(
1415
+ (entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
1416
+ )
1303
1417
  if (!isNew) {
1304
1418
  notes.push('already continuing from an answer; not asking again this turn')
1305
1419
  return { notes }
@@ -1327,7 +1441,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1327
1441
  return { notes }
1328
1442
  }
1329
1443
  try {
1330
- return await escalate(ctx, envelope, sessionId, pending, notes)
1444
+ return await escalate(ctx, envelope, sessionId, unasked, live, notes)
1331
1445
  } finally {
1332
1446
  releaseQuestionPush(sessionId, ctx.env)
1333
1447
  }
@@ -1338,95 +1452,138 @@ async function escalate(
1338
1452
  ctx: HookContext,
1339
1453
  envelope: HookEnvelope,
1340
1454
  sessionId: string,
1341
- pending: PendingQuestion,
1455
+ unasked: PendingQuestion[],
1456
+ alreadyLive: PendingQuestion[],
1342
1457
  notes: string[],
1343
1458
  ): Promise<HookOutcome> {
1344
- // Away right now, but the question still owes the user its terminal-first
1345
- // window before anything reaches their devices.
1346
- const grace = await awaitGrace(ctx, pending.asked_at ?? ctx.now())
1459
+ // Away right now, but the questions still owe the user their terminal-first
1460
+ // window before anything reaches their devices — measured from the oldest
1461
+ // registration, because that is the question that has waited longest.
1462
+ const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
1463
+ const grace = await awaitGrace(ctx, oldest)
1347
1464
  if (grace === 'user-returned') {
1348
- notes.push('you came back before the wait elapsed; leaving the question in the terminal')
1465
+ notes.push('you came back before the wait elapsed; leaving the questions in the terminal')
1349
1466
  return { notes }
1350
1467
  }
1351
1468
  if (grace === 'no-signal') {
1352
1469
  notes.push('no idle signal on this machine; asking now rather than holding the terminal')
1353
1470
  }
1354
1471
 
1355
- const asked = await askAndWait(ctx, {
1356
- title:
1357
- ctx.config.project.value === null
1358
- ? 'Question'
1359
- : `Question · ${ctx.config.project.value}`,
1360
- body: pending.question,
1361
- ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
1362
- event: 'agent_question',
1363
- session: sessionLabel(ctx, envelope),
1364
- // Outlives the block, and stays open on purpose: the answer is still
1365
- // useful to the next turn, which collects it with `notifai replies`.
1366
- windowSeconds: QUESTION_WINDOW_SECONDS,
1367
- onSubmitted: (live) => {
1368
- const livePending: PendingQuestion = {
1369
- ...pending,
1370
- request_id: live.requestId,
1371
- collapse_key: live.collapseKey,
1372
- device_ids: live.devices,
1472
+ // Phase one: every registered question reaches the user's devices, each as
1473
+ // its own notification — one ask never stands in for another.
1474
+ const submitted: PendingQuestion[] = []
1475
+ for (const entry of unasked) {
1476
+ const questions = pendingQuestions(entry)
1477
+ const sent = await submitQuestion(ctx, {
1478
+ title:
1479
+ ctx.config.project.value === null
1480
+ ? 'Question'
1481
+ : `Question · ${ctx.config.project.value}`,
1482
+ // A set is answered on the expanded card; the banner leads with the
1483
+ // first question and says how much more is waiting behind it.
1484
+ body:
1485
+ questions.length > 1
1486
+ ? `${questions[0]!.text} (+${questions.length - 1} more)`
1487
+ : entry.question,
1488
+ questions,
1489
+ ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
1490
+ event: 'agent_question',
1491
+ session: sessionLabel(ctx, envelope),
1492
+ // Outlives the block, and stays open on purpose: the answer is still
1493
+ // useful to the next turn, which collects it with `notifai replies`.
1494
+ windowSeconds: QUESTION_WINDOW_SECONDS,
1495
+ })
1496
+ if ('error' in sent) {
1497
+ notes.push(sent.error)
1498
+ continue
1499
+ }
1500
+ const live: PendingQuestion = {
1501
+ ...entry,
1502
+ request_id: sent.requestId,
1503
+ collapse_key: sent.collapseKey,
1504
+ device_ids: sent.devices,
1505
+ }
1506
+ submitted.push(live)
1507
+ // Record what is now live on the user's devices BEFORE any wait. If we
1508
+ // only learned these ids afterwards, a question that timed out would
1509
+ // leave no trace, and the user returning to the terminal could never
1510
+ // retire it — the notification would stay answerable for an hour with
1511
+ // nobody listening.
1512
+ updateSessionState(sessionId, ctx.env, (current) => {
1513
+ const list = pendingList(current)
1514
+ const index = list.findIndex(
1515
+ (candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
1516
+ )
1517
+ if (index >= 0) {
1518
+ const next = [...list]
1519
+ next[index] = live
1520
+ return { ...current, pending: next }
1373
1521
  }
1374
- updateSessionState(sessionId, ctx.env, (current) => {
1375
- const stillCurrent =
1376
- current.pending?.question === pending.question &&
1377
- current.pending?.asked_at === pending.asked_at &&
1378
- current.pending?.request_id === undefined
1379
- if (stillCurrent) return { ...current, pending: livePending }
1380
-
1381
- // A newer `notifai ask` won while this submit was in flight. Keep it,
1382
- // but now that request identifiers exist, preserve the old delivered
1383
- // question as collectable/retirable instead of overwriting either one.
1384
- const retirement = retiringQuestion(livePending, 'superseded')!
1385
- const retiring = [...(current.retiring ?? [])]
1386
- if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
1387
- retiring.push(retirement)
1388
- }
1389
- return { ...current, retiring }
1390
- })
1391
- },
1392
- })
1393
- if ('error' in asked) {
1394
- notes.push(asked.error)
1522
+ // The entry vanished while the submit was in flight (the user's prompt
1523
+ // wiped the queue). The delivered notification must still be retirable,
1524
+ // so park it rather than lose its only identifiers.
1525
+ const retirement = retiringQuestion(live, 'answered_elsewhere')!
1526
+ const retiring = [...(current.retiring ?? [])]
1527
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1528
+ retiring.push(retirement)
1529
+ }
1530
+ return { ...current, retiring }
1531
+ })
1532
+ }
1533
+
1534
+ const waitingOn = [...alreadyLive, ...submitted]
1535
+ if (waitingOn.length === 0) return { notes }
1536
+
1537
+ if (ctx.harness === 'opencode') {
1538
+ notes.push(
1539
+ `question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`,
1540
+ )
1395
1541
  return { notes }
1396
1542
  }
1397
1543
 
1398
- if (!asked.reply) {
1399
- // Keep the pending record so a returning user's UserPromptSubmit can retire
1400
- // the notification that is still live on their devices. `request_id` being
1401
- // set is also what stops the next Stop pushing the same question again.
1402
- if (!asked.waited) {
1403
- notes.push(
1404
- `question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`,
1405
- )
1406
- } else if (asked.userReturned) {
1544
+ // Phase two: one blocking wait across everything live, old and new alike.
1545
+ const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
1546
+ const waited = await waitForAnyReplyWhileAway(
1547
+ ctx,
1548
+ waitingOn.map((entry) => entry.request_id!),
1549
+ timeoutSeconds,
1550
+ )
1551
+
1552
+ if (waited.byRequest.size === 0) {
1553
+ // Keep the pending records so a returning user's UserPromptSubmit can
1554
+ // retire the notifications still live on their devices. `request_id`
1555
+ // being set is also what stops the next Stop pushing them again.
1556
+ const ids = waitingOn.map((entry) => entry.request_id).join(', ')
1557
+ if (waited.userReturned) {
1407
1558
  notes.push(
1408
- `you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
1409
- 'Retrieve it with: notifai replies --pending',
1559
+ `you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; returning the terminal while ${waitingOn.length === 1 ? 'it stays' : 'they stay'} answerable (${ids}). ` +
1560
+ 'Retrieve answers with: notifai replies --pending',
1410
1561
  )
1411
1562
  } else {
1412
1563
  notes.push(
1413
- asked.degraded
1414
- ? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
1415
- : `no answer in time; retrieve it later with: notifai replies --pending`,
1564
+ waited.degraded
1565
+ ? 'could not reach the server to find out whether you answered; check with: notifai replies --pending'
1566
+ : 'no answer in time; retrieve it later with: notifai replies --pending',
1416
1567
  )
1417
1568
  }
1418
1569
  return { notes }
1419
1570
  }
1420
1571
 
1421
- await finishPendingAnswer(ctx, envelope, sessionId, {
1422
- ...pending,
1423
- request_id: asked.requestId,
1424
- collapse_key: asked.collapseKey,
1425
- device_ids: asked.devices,
1426
- }, asked.reply, true)
1427
- notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
1572
+ const answered: AnsweredPending[] = []
1573
+ for (const entry of waitingOn) {
1574
+ const replies = waited.byRequest.get(entry.request_id!)
1575
+ if (replies === undefined) continue
1576
+ // First answer claims the question across devices; the close is what
1577
+ // enforces that, and the latest reply within the window is the answer.
1578
+ await closeQuietly(ctx, entry.request_id!)
1579
+ answered.push({ pending: entry, reply: replies.at(-1)!, replies })
1580
+ }
1581
+ await finishAnsweredPendings(ctx, envelope, sessionId, answered, true)
1582
+ for (const { reply } of answered) {
1583
+ notes.push(`answer from ${reply.device_name}: ${reply.text}`)
1584
+ }
1428
1585
  return {
1429
- stdout: stopAnswerOutput(asked.reply),
1586
+ stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1430
1587
  notes,
1431
1588
  }
1432
1589
  }
@@ -1455,10 +1612,9 @@ export function handleSessionEnd(
1455
1612
 
1456
1613
  const state = readSessionState(sessionId, env)
1457
1614
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
1458
- const pending = state.pending
1459
- if (pending !== undefined) {
1615
+ for (const entry of pendingList(state)) {
1460
1616
  try {
1461
- const orphan = retiringQuestion(pending, 'expired')
1617
+ const orphan = retiringQuestion(entry, 'expired')
1462
1618
  if (orphan !== null) orphans.push(orphan)
1463
1619
  } catch (err) {
1464
1620
  notes.push(err instanceof Error ? err.message : String(err))
@@ -1483,18 +1639,24 @@ export function handleSessionEnd(
1483
1639
  // ---------------------------------------------------------------------------
1484
1640
 
1485
1641
  /**
1486
- * One session holds one live question, so registering a second one ends the
1487
- * first.
1488
- *
1489
- * This used to replace `pending` wholesale, which silently discarded the
1490
- * `request_id` and `collapse_key` of a question already delivered to the user's
1491
- * devices. Nothing else knows those ids, so the notification became
1492
- * unretirable — it sat on the lock screen for ever asking a question no answer
1493
- * could reach. That is where the stale pile-up came from.
1642
+ * More pending questions than this means an agent is looping, not asking.
1643
+ * Related questions belong in one `ask --form` (the wire carries up to four
1644
+ * questions per notification); four separate pushes is already a lot of lock
1645
+ * screen.
1646
+ */
1647
+ export const MAX_PENDING_QUESTIONS = 4
1648
+
1649
+ /**
1650
+ * A session may hold several registered questions at once: a new `ask` never
1651
+ * ends an earlier one. Each reaches the user as its own notification and is
1652
+ * answerable independently. Superseding is reply semantics — a later reply
1653
+ * corrects an earlier reply to the same question (that correction model lives
1654
+ * server-side) — never question semantics: the old single-slot model silently
1655
+ * discarded a question the moment a second was registered, and the user was
1656
+ * pointed at a notification that never existed (2026-08-09).
1494
1657
  *
1495
- * Supersession is keyed on the session, not the project: several agents may be
1496
- * running in one project at once, and one agent's new question killing another
1497
- * agent's live one would be worse than the staleness this fixes.
1658
+ * The queue is keyed on the session, not the project: several agents may be
1659
+ * running in one project at once, and their questions must never interfere.
1498
1660
  */
1499
1661
  export function registerQuestion(
1500
1662
  sessionId: string,
@@ -1502,27 +1664,31 @@ export function registerQuestion(
1502
1664
  question: PendingQuestion,
1503
1665
  now: number = Date.now(),
1504
1666
  ): void {
1667
+ let full = false
1505
1668
  updateSessionState(sessionId, env, (state) => {
1506
- const retiring = [...(state.retiring ?? [])]
1507
- if (state.pending !== undefined) {
1508
- const retirement = retiringQuestion(state.pending, 'superseded')
1509
- if (
1510
- retirement !== null &&
1511
- !retiring.some((entry) => entry.request_id === retirement.request_id)
1512
- ) {
1513
- retiring.push(retirement)
1514
- }
1669
+ const pending = pendingList(state)
1670
+ if (pending.length >= MAX_PENDING_QUESTIONS) {
1671
+ full = true
1672
+ return state
1515
1673
  }
1516
1674
  return {
1517
1675
  ...state,
1518
- ...(retiring.length > 0 ? { retiring } : {}),
1519
- pending: {
1520
- asked_at: now,
1521
- ...question,
1522
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1523
- },
1676
+ pending: [
1677
+ ...pending,
1678
+ {
1679
+ asked_at: now,
1680
+ ...question,
1681
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1682
+ },
1683
+ ],
1524
1684
  }
1525
1685
  })
1686
+ if (full) {
1687
+ throw new Error(
1688
+ `${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
1689
+ 'Combine related questions into one `notifai ask --form` instead of registering more.',
1690
+ )
1691
+ }
1526
1692
  }
1527
1693
 
1528
1694
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */