@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/dist/hooks.js CHANGED
@@ -451,9 +451,12 @@ const REPLY_PRESENCE_POLL_SECONDS = 5;
451
451
  * because nobody has acted on the silence yet and the next turn can still
452
452
  * collect the answer with `notifai replies`.
453
453
  */
454
- async function askAndWait(ctx, options) {
454
+ /**
455
+ * Put one registered question on the user's devices. Submission only — the
456
+ * wait happens once, across every live question, in the caller.
457
+ */
458
+ async function submitQuestion(ctx, options) {
455
459
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
456
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value;
457
460
  // A draft carrying `reply` is rejected outright if it targets a device that
458
461
  // cannot answer, so resolve the healthy companion platforms explicitly.
459
462
  const answerable = await answerableDevices(ctx);
@@ -476,45 +479,15 @@ async function askAndWait(ctx, options) {
476
479
  if (!build.ok)
477
480
  return { error: build.error };
478
481
  const receipt = await ctx.client.submit({ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
479
- // Record what is now live on the user's devices BEFORE blocking. If we only
480
- // learned these ids after the wait, a question that timed out would leave no
481
- // trace, and the user returning to the terminal could never retire it — the
482
- // notification would stay answerable for an hour with nobody listening.
483
- options.onSubmitted?.({
484
- requestId: receipt.request_id,
485
- collapseKey,
486
- devices: answerable,
487
- });
488
- if (ctx.harness === 'opencode') {
489
- return {
490
- requestId: receipt.request_id,
491
- collapseKey,
492
- reply: null,
493
- replies: [],
494
- devices: answerable,
495
- degraded: false,
496
- waited: false,
497
- userReturned: false,
498
- };
499
- }
500
- const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds);
501
- if (result.replies.length > 0)
502
- await closeQuietly(ctx, receipt.request_id);
503
- return {
504
- requestId: receipt.request_id,
505
- collapseKey,
506
- // The latest reply, not the first: an earlier conflicting answer was
507
- // corrected by the one that followed it. The cross-device race is still
508
- // first-answer-claims — that is what the close above enforces.
509
- reply: result.replies.at(-1) ?? null,
510
- replies: result.replies,
511
- devices: answerable,
512
- degraded: result.degraded,
513
- waited: true,
514
- userReturned: result.userReturned,
515
- };
482
+ return { requestId: receipt.request_id, collapseKey, devices: answerable };
516
483
  }
517
- async function waitForReplyWhileAway(ctx, requestId, timeoutSeconds) {
484
+ /**
485
+ * One blocking wait across every live question. Each round polls all of them
486
+ * concurrently, so several questions cost the same wall clock as one; the
487
+ * first round that finds any reply returns everything found in that round, and
488
+ * whatever was not answered stays registered.
489
+ */
490
+ async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds) {
518
491
  const deadline = ctx.now() + timeoutSeconds * 1000;
519
492
  let degraded = false;
520
493
  let firstPoll = true;
@@ -522,20 +495,31 @@ async function waitForReplyWhileAway(ctx, requestId, timeoutSeconds) {
522
495
  if (ctx.config.require_idle.value) {
523
496
  const idle = ctx.idleSeconds();
524
497
  if (idle !== null && idle < ctx.config.away_after_seconds.value) {
525
- return { replies: [], degraded, userReturned: true };
498
+ return { byRequest: new Map(), degraded, userReturned: true };
526
499
  }
527
500
  }
528
501
  const remainingMs = Math.max(0, deadline - ctx.now());
529
502
  if (!firstPoll && remainingMs === 0) {
530
- return { replies: [], degraded, userReturned: false };
503
+ return { byRequest: new Map(), degraded, userReturned: false };
531
504
  }
532
505
  firstPoll = false;
533
506
  const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
534
- const result = await ctx.waitForFirstReply(requestId, pollSeconds);
535
- degraded ||= result.degraded === true;
536
- if (result.replies.length > 0) {
537
- return { replies: result.replies, degraded, userReturned: false };
507
+ const results = await Promise.all(requestIds.map(async (requestId) => {
508
+ try {
509
+ return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) };
510
+ }
511
+ catch {
512
+ return { requestId, replies: [], degraded: true };
513
+ }
514
+ }));
515
+ const byRequest = new Map();
516
+ for (const result of results) {
517
+ degraded ||= result.degraded === true;
518
+ if (result.replies.length > 0)
519
+ byRequest.set(result.requestId, result.replies);
538
520
  }
521
+ if (byRequest.size > 0)
522
+ return { byRequest, degraded, userReturned: false };
539
523
  }
540
524
  }
541
525
  /**
@@ -790,6 +774,31 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
790
774
  return { reply: null, replies: [], degraded: true, failed: true };
791
775
  }
792
776
  }
777
+ /**
778
+ * One bounded recovery poll across every delivered question, concurrently —
779
+ * N questions must not multiply the hook's latency budget by N.
780
+ */
781
+ async function pollPendingReplies(ctx, live, timeoutSeconds) {
782
+ const polls = await Promise.all(live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)));
783
+ const answered = [];
784
+ let troubled = false;
785
+ for (const [index, poll] of polls.entries()) {
786
+ if (poll.reply !== null) {
787
+ answered.push({ pending: live[index], reply: poll.reply, replies: poll.replies });
788
+ }
789
+ if (poll.failed || poll.degraded)
790
+ troubled = true;
791
+ }
792
+ return { answered, troubled };
793
+ }
794
+ /** The registered-question queue. Anything but the current shape reads as empty. */
795
+ function pendingList(state) {
796
+ return Array.isArray(state.pending) ? state.pending : [];
797
+ }
798
+ /** Registration identity — the convention every racing writer compares by. */
799
+ function isSamePending(a, b) {
800
+ return a.question === b.question && a.asked_at === b.asked_at;
801
+ }
793
802
  /** The question set this pending record pushes, however it was registered. */
794
803
  function pendingQuestions(pending) {
795
804
  return pending.questions ?? [{ id: 'q1', text: pending.question }];
@@ -817,38 +826,64 @@ function answerContext(replies, hadChoices) {
817
826
  `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
818
827
  'Continue with that answer.');
819
828
  }
820
- function userPromptAnswerOutput(replies, hadChoices) {
829
+ /**
830
+ * Every answer that has arrived, as one message. Several registered questions
831
+ * may resolve in one hook pass; the agent reads them together, each answer
832
+ * tied to the question that asked it, with a truthful note about anything
833
+ * still waiting.
834
+ */
835
+ function answersContext(answered, remaining) {
836
+ const tail = remaining > 0
837
+ ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
838
+ : '';
839
+ if (answered.length === 1) {
840
+ const only = answered[0];
841
+ return answerContext(only.replies, pendingHasChoices(only.pending)) + tail;
842
+ }
843
+ const lines = answered.map(({ pending, replies }) => {
844
+ const latest = replies.at(-1);
845
+ const answer = replies.length === 1 || pendingHasChoices(pending)
846
+ ? `"${latest.text}"`
847
+ : `${replies.map((reply) => `"${reply.text}"`).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`;
848
+ return `- "${pending.question}" → ${answer} (from ${latest.device_name})`;
849
+ });
850
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`;
851
+ }
852
+ function userPromptAnswerOutput(context) {
821
853
  return JSON.stringify({
822
854
  hookSpecificOutput: {
823
855
  hookEventName: 'UserPromptSubmit',
824
- additionalContext: answerContext(replies, hadChoices),
856
+ additionalContext: context,
825
857
  },
826
858
  });
827
859
  }
828
- function stopAnswerOutput(replies, hadChoices) {
829
- return JSON.stringify({ decision: 'block', reason: answerContext(replies, hadChoices) });
860
+ function stopAnswerOutput(context) {
861
+ return JSON.stringify({ decision: 'block', reason: context });
830
862
  }
831
863
  /**
832
- * Close, truthfully retire, and forget one answered pending question without
833
- * dropping any other retirement debt. Stop answers also open one bounded
834
- * continuation generation; UserPromptSubmit answers ride the user's new turn.
864
+ * Close, truthfully retire, and forget every answered pending question in one
865
+ * state write, without dropping any other retirement debt or any question
866
+ * still waiting. Stop answers also open one bounded continuation generation;
867
+ * UserPromptSubmit answers ride the user's new turn.
835
868
  */
836
- async function finishPendingAnswer(ctx, envelope, sessionId, pending, reply, continuation) {
837
- const retirement = retiringQuestion(pending, 'answered');
869
+ async function finishAnsweredPendings(ctx, envelope, sessionId, answered, continuation) {
870
+ const retirements = answered
871
+ .map(({ pending }) => retiringQuestion(pending, 'answered'))
872
+ .filter((entry) => entry !== null);
838
873
  updateSessionState(sessionId, ctx.env, (current) => {
839
874
  const retiring = [...(current.retiring ?? [])];
840
- if (retirement !== null) {
875
+ for (const retirement of retirements) {
841
876
  const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id);
842
877
  if (existing < 0)
843
878
  retiring.push(retirement);
844
879
  else
845
880
  retiring[existing] = retirement;
846
881
  }
847
- const samePending = current.pending?.question === pending.question &&
848
- current.pending?.asked_at === pending.asked_at &&
849
- current.pending?.request_id === pending.request_id;
882
+ const remaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
850
883
  const next = { ...current, retiring };
851
- if (samePending)
884
+ if (remaining.length > 0)
885
+ next.pending = remaining;
886
+ else
852
887
  delete next.pending;
853
888
  if (continuation) {
854
889
  next.continuation = {
@@ -878,20 +913,19 @@ export async function handleUserPromptSubmit(ctx, envelope) {
878
913
  if (!sessionId)
879
914
  return { notes };
880
915
  const state = readSessionState(sessionId, ctx.env);
881
- const pending = state.pending;
882
- if (pending?.request_id !== undefined) {
883
- const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS);
884
- if (late.reply !== null) {
885
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false);
886
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
887
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
916
+ const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
917
+ let lateAnswers = [];
918
+ if (live.length > 0) {
919
+ const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
920
+ lateAnswers = answered;
921
+ if (answered.length > 0) {
922
+ await finishAnsweredPendings(ctx, envelope, sessionId, answered, false);
923
+ for (const { reply } of answered) {
924
+ notes.push(`late answer from ${reply.device_name}: ${reply.text}`);
888
925
  }
889
- await drainOrphanRetirements(ctx, ctx.env, ctx.now());
890
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
891
- return { stdout: userPromptAnswerOutput(late.replies, pendingHasChoices(pending)), notes };
892
926
  }
893
- if (late.failed || late.degraded) {
894
- notes.push('could not check the pending question for a late answer before the prompt');
927
+ if (troubled) {
928
+ notes.push('could not check every pending question for a late answer before the prompt');
895
929
  }
896
930
  }
897
931
  // Park before dropping `pending`. If the process dies between these writes,
@@ -899,10 +933,10 @@ export async function handleUserPromptSubmit(ctx, envelope) {
899
933
  // the gap after erasing the only request/collapse/device identifiers.
900
934
  updateSessionState(sessionId, ctx.env, (current) => {
901
935
  const retiring = [...(current.retiring ?? [])];
902
- if (current.pending !== undefined) {
903
- const retirement = retiringQuestion(current.pending, 'answered_elsewhere');
936
+ for (const entry of pendingList(current)) {
937
+ const retirement = retiringQuestion(entry, 'answered_elsewhere');
904
938
  if (retirement !== null &&
905
- !retiring.some((entry) => entry.request_id === retirement.request_id)) {
939
+ !retiring.some((parked) => parked.request_id === retirement.request_id)) {
906
940
  retiring.push(retirement);
907
941
  }
908
942
  }
@@ -920,6 +954,11 @@ export async function handleUserPromptSubmit(ctx, envelope) {
920
954
  if (swept.length > 0) {
921
955
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
922
956
  }
957
+ if (lateAnswers.length > 0) {
958
+ // Whatever the user did not answer was just retired above — they are at
959
+ // the keyboard now, so nothing is "still waiting" from their side.
960
+ return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes };
961
+ }
923
962
  return { notes };
924
963
  }
925
964
  // ---------------------------------------------------------------------------
@@ -954,22 +993,36 @@ export async function handleStop(ctx, envelope) {
954
993
  notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
955
994
  }
956
995
  const state = readSessionState(sessionId, ctx.env);
957
- const pending = state.pending;
958
- if (!pending)
996
+ const pending = pendingList(state);
997
+ if (pending.length === 0)
959
998
  return { notes };
960
- if (pending.request_id !== undefined) {
961
- const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS);
962
- if (late.reply !== null) {
963
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true);
964
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
965
- return { stdout: stopAnswerOutput(late.replies, pendingHasChoices(pending)), notes };
999
+ const live = pending.filter((entry) => entry.request_id !== undefined);
1000
+ const unasked = pending.filter((entry) => entry.request_id === undefined);
1001
+ if (live.length > 0) {
1002
+ const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS);
1003
+ if (answered.length > 0) {
1004
+ await finishAnsweredPendings(ctx, envelope, sessionId, answered, true);
1005
+ for (const { reply } of answered) {
1006
+ notes.push(`late answer from ${reply.device_name}: ${reply.text}`);
1007
+ }
1008
+ // Anything still unasked rides the next Stop: the agent is being resumed
1009
+ // with answers right now, and may not even need the rest afterwards.
1010
+ return {
1011
+ stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
1012
+ notes,
1013
+ };
966
1014
  }
967
- // Already live on the user's devices from an earlier Stop; asking twice for
968
- // one question is the nagging failure this feature exists to avoid.
969
- notes.push(late.failed || late.degraded
970
- ? `already asked (${pending.request_id}); could not check whether its answer arrived`
971
- : `already asked (${pending.request_id}); waiting for that answer`);
972
- return { notes };
1015
+ if (unasked.length === 0) {
1016
+ // Already live on the user's devices from an earlier Stop; asking twice
1017
+ // for one question is the nagging failure this feature exists to avoid.
1018
+ const ids = live.map((entry) => entry.request_id).join(', ');
1019
+ notes.push(troubled
1020
+ ? `already asked (${ids}); could not check whether ${live.length === 1 ? 'its answer' : 'their answers'} arrived`
1021
+ : `already asked (${ids}); waiting for ${live.length === 1 ? 'that answer' : 'those answers'}`);
1022
+ return { notes };
1023
+ }
1024
+ // Questions registered after the earlier push still owe the user their
1025
+ // notification; fall through and escalate just those.
973
1026
  }
974
1027
  // A Stop answer may immediately produce a legitimate follow-up question.
975
1028
  // Allow that new generation, but never re-run an old pending question and
@@ -977,8 +1030,7 @@ export async function handleStop(ctx, envelope) {
977
1030
  if (envelope.stop_hook_active === true) {
978
1031
  const continuation = state.continuation;
979
1032
  const isNew = continuation !== undefined &&
980
- pending.asked_at !== undefined &&
981
- pending.asked_at > continuation.answered_at;
1033
+ unasked.some((entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at);
982
1034
  if (!isNew) {
983
1035
  notes.push('already continuing from an answer; not asking again this turn');
984
1036
  return { notes };
@@ -1004,97 +1056,125 @@ export async function handleStop(ctx, envelope) {
1004
1056
  return { notes };
1005
1057
  }
1006
1058
  try {
1007
- return await escalate(ctx, envelope, sessionId, pending, notes);
1059
+ return await escalate(ctx, envelope, sessionId, unasked, live, notes);
1008
1060
  }
1009
1061
  finally {
1010
1062
  releaseQuestionPush(sessionId, ctx.env);
1011
1063
  }
1012
1064
  }
1013
1065
  /** The escalation itself, split out so the claim is released on every path. */
1014
- async function escalate(ctx, envelope, sessionId, pending, notes) {
1015
- // Away right now, but the question still owes the user its terminal-first
1016
- // window before anything reaches their devices.
1017
- const grace = await awaitGrace(ctx, pending.asked_at ?? ctx.now());
1066
+ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes) {
1067
+ // Away right now, but the questions still owe the user their terminal-first
1068
+ // window before anything reaches their devices — measured from the oldest
1069
+ // registration, because that is the question that has waited longest.
1070
+ const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
1071
+ const grace = await awaitGrace(ctx, oldest);
1018
1072
  if (grace === 'user-returned') {
1019
- notes.push('you came back before the wait elapsed; leaving the question in the terminal');
1073
+ notes.push('you came back before the wait elapsed; leaving the questions in the terminal');
1020
1074
  return { notes };
1021
1075
  }
1022
1076
  if (grace === 'no-signal') {
1023
1077
  notes.push('no idle signal on this machine; asking now rather than holding the terminal');
1024
1078
  }
1025
- const questions = pendingQuestions(pending);
1026
- const asked = await askAndWait(ctx, {
1027
- title: ctx.config.project.value === null
1028
- ? 'Question'
1029
- : `Question · ${ctx.config.project.value}`,
1030
- // A set is answered on the expanded card; the banner leads with the first
1031
- // question and says how much more is waiting behind it.
1032
- body: questions.length > 1
1033
- ? `${questions[0].text} (+${questions.length - 1} more)`
1034
- : pending.question,
1035
- questions,
1036
- ...(pending.detail !== undefined ? { detail: pending.detail } : {}),
1037
- event: 'agent_question',
1038
- session: sessionLabel(ctx, envelope),
1039
- // Outlives the block, and stays open on purpose: the answer is still
1040
- // useful to the next turn, which collects it with `notifai replies`.
1041
- windowSeconds: QUESTION_WINDOW_SECONDS,
1042
- onSubmitted: (live) => {
1043
- const livePending = {
1044
- ...pending,
1045
- request_id: live.requestId,
1046
- collapse_key: live.collapseKey,
1047
- device_ids: live.devices,
1048
- };
1049
- updateSessionState(sessionId, ctx.env, (current) => {
1050
- const stillCurrent = current.pending?.question === pending.question &&
1051
- current.pending?.asked_at === pending.asked_at &&
1052
- current.pending?.request_id === undefined;
1053
- if (stillCurrent)
1054
- return { ...current, pending: livePending };
1055
- // A newer `notifai ask` won while this submit was in flight. Keep it,
1056
- // but now that request identifiers exist, preserve the old delivered
1057
- // question as collectable/retirable instead of overwriting either one.
1058
- const retirement = retiringQuestion(livePending, 'superseded');
1059
- const retiring = [...(current.retiring ?? [])];
1060
- if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
1061
- retiring.push(retirement);
1062
- }
1063
- return { ...current, retiring };
1064
- });
1065
- },
1066
- });
1067
- if ('error' in asked) {
1068
- notes.push(asked.error);
1079
+ // Phase one: every registered question reaches the user's devices, each as
1080
+ // its own notification — one ask never stands in for another.
1081
+ const submitted = [];
1082
+ for (const entry of unasked) {
1083
+ const questions = pendingQuestions(entry);
1084
+ const sent = await submitQuestion(ctx, {
1085
+ title: ctx.config.project.value === null
1086
+ ? 'Question'
1087
+ : `Question · ${ctx.config.project.value}`,
1088
+ // A set is answered on the expanded card; the banner leads with the
1089
+ // first question and says how much more is waiting behind it.
1090
+ body: questions.length > 1
1091
+ ? `${questions[0].text} (+${questions.length - 1} more)`
1092
+ : entry.question,
1093
+ questions,
1094
+ ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
1095
+ event: 'agent_question',
1096
+ session: sessionLabel(ctx, envelope),
1097
+ // Outlives the block, and stays open on purpose: the answer is still
1098
+ // useful to the next turn, which collects it with `notifai replies`.
1099
+ windowSeconds: QUESTION_WINDOW_SECONDS,
1100
+ });
1101
+ if ('error' in sent) {
1102
+ notes.push(sent.error);
1103
+ continue;
1104
+ }
1105
+ const live = {
1106
+ ...entry,
1107
+ request_id: sent.requestId,
1108
+ collapse_key: sent.collapseKey,
1109
+ device_ids: sent.devices,
1110
+ };
1111
+ submitted.push(live);
1112
+ // Record what is now live on the user's devices BEFORE any wait. If we
1113
+ // only learned these ids afterwards, a question that timed out would
1114
+ // leave no trace, and the user returning to the terminal could never
1115
+ // retire it — the notification would stay answerable for an hour with
1116
+ // nobody listening.
1117
+ updateSessionState(sessionId, ctx.env, (current) => {
1118
+ const list = pendingList(current);
1119
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
1120
+ if (index >= 0) {
1121
+ const next = [...list];
1122
+ next[index] = live;
1123
+ return { ...current, pending: next };
1124
+ }
1125
+ // The entry vanished while the submit was in flight (the user's prompt
1126
+ // wiped the queue). The delivered notification must still be retirable,
1127
+ // so park it rather than lose its only identifiers.
1128
+ const retirement = retiringQuestion(live, 'answered_elsewhere');
1129
+ const retiring = [...(current.retiring ?? [])];
1130
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1131
+ retiring.push(retirement);
1132
+ }
1133
+ return { ...current, retiring };
1134
+ });
1135
+ }
1136
+ const waitingOn = [...alreadyLive, ...submitted];
1137
+ if (waitingOn.length === 0)
1138
+ return { notes };
1139
+ if (ctx.harness === 'opencode') {
1140
+ notes.push(`question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`);
1069
1141
  return { notes };
1070
1142
  }
1071
- if (!asked.reply) {
1072
- // Keep the pending record so a returning user's UserPromptSubmit can retire
1073
- // the notification that is still live on their devices. `request_id` being
1074
- // set is also what stops the next Stop pushing the same question again.
1075
- if (!asked.waited) {
1076
- notes.push(`question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`);
1077
- }
1078
- else if (asked.userReturned) {
1079
- notes.push(`you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
1080
- 'Retrieve it with: notifai replies --pending');
1143
+ // Phase two: one blocking wait across everything live, old and new alike.
1144
+ const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value;
1145
+ const waited = await waitForAnyReplyWhileAway(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
1146
+ if (waited.byRequest.size === 0) {
1147
+ // Keep the pending records so a returning user's UserPromptSubmit can
1148
+ // retire the notifications still live on their devices. `request_id`
1149
+ // being set is also what stops the next Stop pushing them again.
1150
+ const ids = waitingOn.map((entry) => entry.request_id).join(', ');
1151
+ if (waited.userReturned) {
1152
+ notes.push(`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}). ` +
1153
+ 'Retrieve answers with: notifai replies --pending');
1081
1154
  }
1082
1155
  else {
1083
- notes.push(asked.degraded
1084
- ? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
1085
- : `no answer in time; retrieve it later with: notifai replies --pending`);
1156
+ notes.push(waited.degraded
1157
+ ? 'could not reach the server to find out whether you answered; check with: notifai replies --pending'
1158
+ : 'no answer in time; retrieve it later with: notifai replies --pending');
1086
1159
  }
1087
1160
  return { notes };
1088
1161
  }
1089
- await finishPendingAnswer(ctx, envelope, sessionId, {
1090
- ...pending,
1091
- request_id: asked.requestId,
1092
- collapse_key: asked.collapseKey,
1093
- device_ids: asked.devices,
1094
- }, asked.reply, true);
1095
- notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`);
1162
+ const answered = [];
1163
+ for (const entry of waitingOn) {
1164
+ const replies = waited.byRequest.get(entry.request_id);
1165
+ if (replies === undefined)
1166
+ continue;
1167
+ // First answer claims the question across devices; the close is what
1168
+ // enforces that, and the latest reply within the window is the answer.
1169
+ await closeQuietly(ctx, entry.request_id);
1170
+ answered.push({ pending: entry, reply: replies.at(-1), replies });
1171
+ }
1172
+ await finishAnsweredPendings(ctx, envelope, sessionId, answered, true);
1173
+ for (const { reply } of answered) {
1174
+ notes.push(`answer from ${reply.device_name}: ${reply.text}`);
1175
+ }
1096
1176
  return {
1097
- stdout: stopAnswerOutput(asked.replies, pendingHasChoices(pending)),
1177
+ stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1098
1178
  notes,
1099
1179
  };
1100
1180
  }
@@ -1118,10 +1198,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1118
1198
  clearMatchingProjectSession(envelope.cwd, env, sessionId);
1119
1199
  const state = readSessionState(sessionId, env);
1120
1200
  const orphans = [...(state.retiring ?? [])];
1121
- const pending = state.pending;
1122
- if (pending !== undefined) {
1201
+ for (const entry of pendingList(state)) {
1123
1202
  try {
1124
- const orphan = retiringQuestion(pending, 'expired');
1203
+ const orphan = retiringQuestion(entry, 'expired');
1125
1204
  if (orphan !== null)
1126
1205
  orphans.push(orphan);
1127
1206
  }
@@ -1144,39 +1223,48 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1144
1223
  // ask — register a question for the turn's end
1145
1224
  // ---------------------------------------------------------------------------
1146
1225
  /**
1147
- * One session holds one live question, so registering a second one ends the
1148
- * first.
1149
- *
1150
- * This used to replace `pending` wholesale, which silently discarded the
1151
- * `request_id` and `collapse_key` of a question already delivered to the user's
1152
- * devices. Nothing else knows those ids, so the notification became
1153
- * unretirable — it sat on the lock screen for ever asking a question no answer
1154
- * could reach. That is where the stale pile-up came from.
1226
+ * More pending questions than this means an agent is looping, not asking.
1227
+ * Related questions belong in one `ask --form` (the wire carries up to four
1228
+ * questions per notification); four separate pushes is already a lot of lock
1229
+ * screen.
1230
+ */
1231
+ export const MAX_PENDING_QUESTIONS = 4;
1232
+ /**
1233
+ * A session may hold several registered questions at once: a new `ask` never
1234
+ * ends an earlier one. Each reaches the user as its own notification and is
1235
+ * answerable independently. Superseding is reply semantics — a later reply
1236
+ * corrects an earlier reply to the same question (that correction model lives
1237
+ * server-side) — never question semantics: the old single-slot model silently
1238
+ * discarded a question the moment a second was registered, and the user was
1239
+ * pointed at a notification that never existed (2026-08-09).
1155
1240
  *
1156
- * Supersession is keyed on the session, not the project: several agents may be
1157
- * running in one project at once, and one agent's new question killing another
1158
- * agent's live one would be worse than the staleness this fixes.
1241
+ * The queue is keyed on the session, not the project: several agents may be
1242
+ * running in one project at once, and their questions must never interfere.
1159
1243
  */
1160
1244
  export function registerQuestion(sessionId, env, question, now = Date.now()) {
1245
+ let full = false;
1161
1246
  updateSessionState(sessionId, env, (state) => {
1162
- const retiring = [...(state.retiring ?? [])];
1163
- if (state.pending !== undefined) {
1164
- const retirement = retiringQuestion(state.pending, 'superseded');
1165
- if (retirement !== null &&
1166
- !retiring.some((entry) => entry.request_id === retirement.request_id)) {
1167
- retiring.push(retirement);
1168
- }
1247
+ const pending = pendingList(state);
1248
+ if (pending.length >= MAX_PENDING_QUESTIONS) {
1249
+ full = true;
1250
+ return state;
1169
1251
  }
1170
1252
  return {
1171
1253
  ...state,
1172
- ...(retiring.length > 0 ? { retiring } : {}),
1173
- pending: {
1174
- asked_at: now,
1175
- ...question,
1176
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1177
- },
1254
+ pending: [
1255
+ ...pending,
1256
+ {
1257
+ asked_at: now,
1258
+ ...question,
1259
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1260
+ },
1261
+ ],
1178
1262
  };
1179
1263
  });
1264
+ if (full) {
1265
+ throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
1266
+ 'Combine related questions into one `notifai ask --form` instead of registering more.');
1267
+ }
1180
1268
  }
1181
1269
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
1182
1270
  export function parseHookInput(raw) {