@raidiant/notifai 0.2.0 → 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
@@ -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. */
@@ -640,21 +647,11 @@ export interface HookContext {
640
647
 
641
648
  export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
642
649
 
643
- interface AskResult {
650
+ interface SubmittedQuestion {
644
651
  requestId: string
645
652
  collapseKey: string
646
- /** The answer to act on: the latest reply, because a later one corrects. */
647
- reply: ReplyView | null
648
- /** Every reply in arrival order, for free-text answers given in parts. */
649
- replies: ReplyView[]
650
653
  /** The devices the question went to; retirement must not reach any other. */
651
654
  devices: string[]
652
- /** The wait ended amid network failures, so "no answer" is unproven. */
653
- degraded: boolean
654
- /** False when the harness cannot consume a Stop answer and polling was skipped. */
655
- waited: boolean
656
- /** The user returned to this machine after the question was pushed. */
657
- userReturned: boolean
658
655
  }
659
656
 
660
657
  /** Re-check local presence at this cadence while a pushed question is waiting. */
@@ -671,7 +668,11 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
671
668
  * because nobody has acted on the silence yet and the next turn can still
672
669
  * collect the answer with `notifai replies`.
673
670
  */
674
- 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(
675
676
  ctx: HookContext,
676
677
  options: {
677
678
  title: string
@@ -683,12 +684,9 @@ async function askAndWait(
683
684
  session?: string | undefined
684
685
  /** How long the server keeps accepting an answer. */
685
686
  windowSeconds: number
686
- /** Called once the question is live, before the block begins. */
687
- onSubmitted?: (live: { requestId: string; collapseKey: string; devices: string[] }) => void
688
687
  },
689
- ): Promise<AskResult | { error: string }> {
688
+ ): Promise<SubmittedQuestion | { error: string }> {
690
689
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
691
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
692
690
  // A draft carrying `reply` is rejected outright if it targets a device that
693
691
  // cannot answer, so resolve the healthy companion platforms explicitly.
694
692
  const answerable = await answerableDevices(ctx)
@@ -714,50 +712,20 @@ async function askAndWait(
714
712
  { idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
715
713
  0,
716
714
  )
717
- // Record what is now live on the user's devices BEFORE blocking. If we only
718
- // learned these ids after the wait, a question that timed out would leave no
719
- // trace, and the user returning to the terminal could never retire it — the
720
- // notification would stay answerable for an hour with nobody listening.
721
- options.onSubmitted?.({
722
- requestId: receipt.request_id,
723
- collapseKey,
724
- devices: answerable,
725
- })
726
- if (ctx.harness === 'opencode') {
727
- return {
728
- requestId: receipt.request_id,
729
- collapseKey,
730
- reply: null,
731
- replies: [],
732
- devices: answerable,
733
- degraded: false,
734
- waited: false,
735
- userReturned: false,
736
- }
737
- }
738
-
739
- const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds)
740
- if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
741
- return {
742
- requestId: receipt.request_id,
743
- collapseKey,
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,
749
- devices: answerable,
750
- degraded: result.degraded,
751
- waited: true,
752
- userReturned: result.userReturned,
753
- }
715
+ return { requestId: receipt.request_id, collapseKey, devices: answerable }
754
716
  }
755
717
 
756
- 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(
757
725
  ctx: HookContext,
758
- requestId: string,
726
+ requestIds: string[],
759
727
  timeoutSeconds: number,
760
- ): Promise<{ replies: ReplyView[]; degraded: boolean; userReturned: boolean }> {
728
+ ): Promise<{ byRequest: Map<string, ReplyView[]>; degraded: boolean; userReturned: boolean }> {
761
729
  const deadline = ctx.now() + timeoutSeconds * 1000
762
730
  let degraded = false
763
731
  let firstPoll = true
@@ -766,24 +734,34 @@ async function waitForReplyWhileAway(
766
734
  if (ctx.config.require_idle.value) {
767
735
  const idle = ctx.idleSeconds()
768
736
  if (idle !== null && idle < ctx.config.away_after_seconds.value) {
769
- return { replies: [], degraded, userReturned: true }
737
+ return { byRequest: new Map(), degraded, userReturned: true }
770
738
  }
771
739
  }
772
740
 
773
741
  const remainingMs = Math.max(0, deadline - ctx.now())
774
742
  if (!firstPoll && remainingMs === 0) {
775
- return { replies: [], degraded, userReturned: false }
743
+ return { byRequest: new Map(), degraded, userReturned: false }
776
744
  }
777
745
  firstPoll = false
778
746
  const pollSeconds = Math.min(
779
747
  REPLY_PRESENCE_POLL_SECONDS,
780
748
  Math.max(0, Math.ceil(remainingMs / 1000)),
781
749
  )
782
- const result = await ctx.waitForFirstReply(requestId, pollSeconds)
783
- degraded ||= result.degraded === true
784
- if (result.replies.length > 0) {
785
- 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)
786
763
  }
764
+ if (byRequest.size > 0) return { byRequest, degraded, userReturned: false }
787
765
  }
788
766
  }
789
767
 
@@ -1133,6 +1111,46 @@ async function pollPendingReply(
1133
1111
  }
1134
1112
  }
1135
1113
 
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
+
1136
1154
  /** The question set this pending record pushes, however it was registered. */
1137
1155
  function pendingQuestions(pending: PendingQuestion): QuestionT[] {
1138
1156
  return pending.questions ?? [{ id: 'q1', text: pending.question }]
@@ -1164,46 +1182,74 @@ function answerContext(replies: ReplyView[], hadChoices: boolean): string {
1164
1182
  )
1165
1183
  }
1166
1184
 
1167
- function userPromptAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
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}`
1209
+ }
1210
+
1211
+ function userPromptAnswerOutput(context: string): string {
1168
1212
  return JSON.stringify({
1169
1213
  hookSpecificOutput: {
1170
1214
  hookEventName: 'UserPromptSubmit',
1171
- additionalContext: answerContext(replies, hadChoices),
1215
+ additionalContext: context,
1172
1216
  },
1173
1217
  })
1174
1218
  }
1175
1219
 
1176
- function stopAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
1177
- return JSON.stringify({ decision: 'block', reason: answerContext(replies, hadChoices) })
1220
+ function stopAnswerOutput(context: string): string {
1221
+ return JSON.stringify({ decision: 'block', reason: context })
1178
1222
  }
1179
1223
 
1180
1224
  /**
1181
- * Close, truthfully retire, and forget one answered pending question without
1182
- * dropping any other retirement debt. Stop answers also open one bounded
1183
- * 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.
1184
1229
  */
1185
- async function finishPendingAnswer(
1230
+ async function finishAnsweredPendings(
1186
1231
  ctx: HookContext,
1187
1232
  envelope: HookEnvelope,
1188
1233
  sessionId: string,
1189
- pending: PendingQuestion,
1190
- reply: ReplyView,
1234
+ answered: AnsweredPending[],
1191
1235
  continuation: boolean,
1192
1236
  ): Promise<void> {
1193
- const retirement = retiringQuestion(pending, 'answered')
1237
+ const retirements = answered
1238
+ .map(({ pending }) => retiringQuestion(pending, 'answered'))
1239
+ .filter((entry): entry is RetiringQuestion => entry !== null)
1194
1240
  updateSessionState(sessionId, ctx.env, (current) => {
1195
1241
  const retiring = [...(current.retiring ?? [])]
1196
- if (retirement !== null) {
1242
+ for (const retirement of retirements) {
1197
1243
  const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
1198
1244
  if (existing < 0) retiring.push(retirement)
1199
1245
  else retiring[existing] = retirement
1200
1246
  }
1201
- const samePending =
1202
- current.pending?.question === pending.question &&
1203
- current.pending?.asked_at === pending.asked_at &&
1204
- current.pending?.request_id === pending.request_id
1205
- const next = { ...current, retiring }
1206
- 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
1207
1253
  if (continuation) {
1208
1254
  next.continuation = {
1209
1255
  answered_at: ctx.now(),
@@ -1237,20 +1283,19 @@ export async function handleUserPromptSubmit(
1237
1283
  if (!sessionId) return { notes }
1238
1284
 
1239
1285
  const state = readSessionState(sessionId, ctx.env)
1240
- const pending = state.pending
1241
- if (pending?.request_id !== undefined) {
1242
- const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS)
1243
- if (late.reply !== null) {
1244
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false)
1245
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1246
- 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}`)
1247
1295
  }
1248
- await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1249
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1250
- return { stdout: userPromptAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
1251
1296
  }
1252
- if (late.failed || late.degraded) {
1253
- 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')
1254
1299
  }
1255
1300
  }
1256
1301
  // Park before dropping `pending`. If the process dies between these writes,
@@ -1258,11 +1303,11 @@ export async function handleUserPromptSubmit(
1258
1303
  // the gap after erasing the only request/collapse/device identifiers.
1259
1304
  updateSessionState(sessionId, ctx.env, (current) => {
1260
1305
  const retiring = [...(current.retiring ?? [])]
1261
- if (current.pending !== undefined) {
1262
- const retirement = retiringQuestion(current.pending, 'answered_elsewhere')
1306
+ for (const entry of pendingList(current)) {
1307
+ const retirement = retiringQuestion(entry, 'answered_elsewhere')
1263
1308
  if (
1264
1309
  retirement !== null &&
1265
- !retiring.some((entry) => entry.request_id === retirement.request_id)
1310
+ !retiring.some((parked) => parked.request_id === retirement.request_id)
1266
1311
  ) {
1267
1312
  retiring.push(retirement)
1268
1313
  }
@@ -1282,6 +1327,11 @@ export async function handleUserPromptSubmit(
1282
1327
  if (swept.length > 0) {
1283
1328
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1284
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
+ }
1285
1335
  return { notes }
1286
1336
  }
1287
1337
 
@@ -1320,23 +1370,38 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1320
1370
  }
1321
1371
 
1322
1372
  const state = readSessionState(sessionId, ctx.env)
1323
- const pending = state.pending
1324
- if (!pending) return { notes }
1325
- if (pending.request_id !== undefined) {
1326
- const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS)
1327
- if (late.reply !== null) {
1328
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
1329
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1330
- return { stdout: stopAnswerOutput(late.replies, pendingHasChoices(pending)), 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
+ }
1331
1391
  }
1332
- // Already live on the user's devices from an earlier Stop; asking twice for
1333
- // one question is the nagging failure this feature exists to avoid.
1334
- notes.push(
1335
- late.failed || late.degraded
1336
- ? `already asked (${pending.request_id}); could not check whether its answer arrived`
1337
- : `already asked (${pending.request_id}); waiting for that answer`,
1338
- )
1339
- 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.
1340
1405
  }
1341
1406
 
1342
1407
  // A Stop answer may immediately produce a legitimate follow-up question.
@@ -1346,8 +1411,9 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1346
1411
  const continuation = state.continuation
1347
1412
  const isNew =
1348
1413
  continuation !== undefined &&
1349
- pending.asked_at !== undefined &&
1350
- pending.asked_at > continuation.answered_at
1414
+ unasked.some(
1415
+ (entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
1416
+ )
1351
1417
  if (!isNew) {
1352
1418
  notes.push('already continuing from an answer; not asking again this turn')
1353
1419
  return { notes }
@@ -1375,7 +1441,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
1375
1441
  return { notes }
1376
1442
  }
1377
1443
  try {
1378
- return await escalate(ctx, envelope, sessionId, pending, notes)
1444
+ return await escalate(ctx, envelope, sessionId, unasked, live, notes)
1379
1445
  } finally {
1380
1446
  releaseQuestionPush(sessionId, ctx.env)
1381
1447
  }
@@ -1386,102 +1452,138 @@ async function escalate(
1386
1452
  ctx: HookContext,
1387
1453
  envelope: HookEnvelope,
1388
1454
  sessionId: string,
1389
- pending: PendingQuestion,
1455
+ unasked: PendingQuestion[],
1456
+ alreadyLive: PendingQuestion[],
1390
1457
  notes: string[],
1391
1458
  ): Promise<HookOutcome> {
1392
- // Away right now, but the question still owes the user its terminal-first
1393
- // window before anything reaches their devices.
1394
- 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)
1395
1464
  if (grace === 'user-returned') {
1396
- 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')
1397
1466
  return { notes }
1398
1467
  }
1399
1468
  if (grace === 'no-signal') {
1400
1469
  notes.push('no idle signal on this machine; asking now rather than holding the terminal')
1401
1470
  }
1402
1471
 
1403
- const questions = pendingQuestions(pending)
1404
- const asked = await askAndWait(ctx, {
1405
- title:
1406
- ctx.config.project.value === null
1407
- ? 'Question'
1408
- : `Question · ${ctx.config.project.value}`,
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 } : {}),
1417
- event: 'agent_question',
1418
- session: sessionLabel(ctx, envelope),
1419
- // Outlives the block, and stays open on purpose: the answer is still
1420
- // useful to the next turn, which collects it with `notifai replies`.
1421
- windowSeconds: QUESTION_WINDOW_SECONDS,
1422
- onSubmitted: (live) => {
1423
- const livePending: PendingQuestion = {
1424
- ...pending,
1425
- request_id: live.requestId,
1426
- collapse_key: live.collapseKey,
1427
- 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 }
1428
1521
  }
1429
- updateSessionState(sessionId, ctx.env, (current) => {
1430
- const stillCurrent =
1431
- current.pending?.question === pending.question &&
1432
- current.pending?.asked_at === pending.asked_at &&
1433
- current.pending?.request_id === undefined
1434
- if (stillCurrent) return { ...current, pending: livePending }
1435
-
1436
- // A newer `notifai ask` won while this submit was in flight. Keep it,
1437
- // but now that request identifiers exist, preserve the old delivered
1438
- // question as collectable/retirable instead of overwriting either one.
1439
- const retirement = retiringQuestion(livePending, 'superseded')!
1440
- const retiring = [...(current.retiring ?? [])]
1441
- if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
1442
- retiring.push(retirement)
1443
- }
1444
- return { ...current, retiring }
1445
- })
1446
- },
1447
- })
1448
- if ('error' in asked) {
1449
- 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
+ )
1450
1541
  return { notes }
1451
1542
  }
1452
1543
 
1453
- if (!asked.reply) {
1454
- // Keep the pending record so a returning user's UserPromptSubmit can retire
1455
- // the notification that is still live on their devices. `request_id` being
1456
- // set is also what stops the next Stop pushing the same question again.
1457
- if (!asked.waited) {
1458
- notes.push(
1459
- `question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`,
1460
- )
1461
- } 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) {
1462
1558
  notes.push(
1463
- `you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
1464
- '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',
1465
1561
  )
1466
1562
  } else {
1467
1563
  notes.push(
1468
- asked.degraded
1469
- ? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
1470
- : `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',
1471
1567
  )
1472
1568
  }
1473
1569
  return { notes }
1474
1570
  }
1475
1571
 
1476
- await finishPendingAnswer(ctx, envelope, sessionId, {
1477
- ...pending,
1478
- request_id: asked.requestId,
1479
- collapse_key: asked.collapseKey,
1480
- device_ids: asked.devices,
1481
- }, asked.reply, true)
1482
- 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
+ }
1483
1585
  return {
1484
- stdout: stopAnswerOutput(asked.replies, pendingHasChoices(pending)),
1586
+ stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1485
1587
  notes,
1486
1588
  }
1487
1589
  }
@@ -1510,10 +1612,9 @@ export function handleSessionEnd(
1510
1612
 
1511
1613
  const state = readSessionState(sessionId, env)
1512
1614
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
1513
- const pending = state.pending
1514
- if (pending !== undefined) {
1615
+ for (const entry of pendingList(state)) {
1515
1616
  try {
1516
- const orphan = retiringQuestion(pending, 'expired')
1617
+ const orphan = retiringQuestion(entry, 'expired')
1517
1618
  if (orphan !== null) orphans.push(orphan)
1518
1619
  } catch (err) {
1519
1620
  notes.push(err instanceof Error ? err.message : String(err))
@@ -1538,18 +1639,24 @@ export function handleSessionEnd(
1538
1639
  // ---------------------------------------------------------------------------
1539
1640
 
1540
1641
  /**
1541
- * One session holds one live question, so registering a second one ends the
1542
- * first.
1543
- *
1544
- * This used to replace `pending` wholesale, which silently discarded the
1545
- * `request_id` and `collapse_key` of a question already delivered to the user's
1546
- * devices. Nothing else knows those ids, so the notification became
1547
- * unretirable — it sat on the lock screen for ever asking a question no answer
1548
- * 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).
1549
1657
  *
1550
- * Supersession is keyed on the session, not the project: several agents may be
1551
- * running in one project at once, and one agent's new question killing another
1552
- * 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.
1553
1660
  */
1554
1661
  export function registerQuestion(
1555
1662
  sessionId: string,
@@ -1557,27 +1664,31 @@ export function registerQuestion(
1557
1664
  question: PendingQuestion,
1558
1665
  now: number = Date.now(),
1559
1666
  ): void {
1667
+ let full = false
1560
1668
  updateSessionState(sessionId, env, (state) => {
1561
- const retiring = [...(state.retiring ?? [])]
1562
- if (state.pending !== undefined) {
1563
- const retirement = retiringQuestion(state.pending, 'superseded')
1564
- if (
1565
- retirement !== null &&
1566
- !retiring.some((entry) => entry.request_id === retirement.request_id)
1567
- ) {
1568
- retiring.push(retirement)
1569
- }
1669
+ const pending = pendingList(state)
1670
+ if (pending.length >= MAX_PENDING_QUESTIONS) {
1671
+ full = true
1672
+ return state
1570
1673
  }
1571
1674
  return {
1572
1675
  ...state,
1573
- ...(retiring.length > 0 ? { retiring } : {}),
1574
- pending: {
1575
- asked_at: now,
1576
- ...question,
1577
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1578
- },
1676
+ pending: [
1677
+ ...pending,
1678
+ {
1679
+ asked_at: now,
1680
+ ...question,
1681
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1682
+ },
1683
+ ],
1579
1684
  }
1580
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
+ }
1581
1692
  }
1582
1693
 
1583
1694
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */