@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/dist/hooks.js CHANGED
@@ -443,14 +443,20 @@ const REPLY_PRESENCE_POLL_SECONDS = 5;
443
443
  /**
444
444
  * Push a question and block for the answer.
445
445
  *
446
- * An answered question is closed immediately: the first answer wins, and the
447
- * other devices must stop offering to change it. An unanswered one stays open,
446
+ * An answered question is closed the moment this wait sees the answer: the
447
+ * first device to answer claims the question and the others are told. Until
448
+ * that close, a later reply is a correction — so what this returns as "the"
449
+ * answer is always the latest reply, with the full stream alongside for
450
+ * free-text answers given in parts. An unanswered question stays open,
448
451
  * because nobody has acted on the silence yet and the next turn can still
449
452
  * collect the answer with `notifai replies`.
450
453
  */
451
- 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) {
452
459
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
453
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value;
454
460
  // A draft carrying `reply` is rejected outright if it targets a device that
455
461
  // cannot answer, so resolve the healthy companion platforms explicitly.
456
462
  const answerable = await answerableDevices(ctx);
@@ -466,46 +472,22 @@ async function askAndWait(ctx, options) {
466
472
  device: answerable,
467
473
  reply: true,
468
474
  replyWindow: Math.max(60, options.windowSeconds),
469
- ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
475
+ questions: options.questions,
476
+ ...(options.detail !== undefined ? { detail: options.detail } : {}),
470
477
  collapseKey,
471
478
  });
472
479
  if (!build.ok)
473
480
  return { error: build.error };
474
481
  const receipt = await ctx.client.submit({ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
475
- // Record what is now live on the user's devices BEFORE blocking. If we only
476
- // learned these ids after the wait, a question that timed out would leave no
477
- // trace, and the user returning to the terminal could never retire it — the
478
- // notification would stay answerable for an hour with nobody listening.
479
- options.onSubmitted?.({
480
- requestId: receipt.request_id,
481
- collapseKey,
482
- devices: answerable,
483
- });
484
- if (ctx.harness === 'opencode') {
485
- return {
486
- requestId: receipt.request_id,
487
- collapseKey,
488
- reply: null,
489
- devices: answerable,
490
- degraded: false,
491
- waited: false,
492
- userReturned: false,
493
- };
494
- }
495
- const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds);
496
- if (result.replies.length > 0)
497
- await closeQuietly(ctx, receipt.request_id);
498
- return {
499
- requestId: receipt.request_id,
500
- collapseKey,
501
- reply: result.replies[0] ?? null,
502
- devices: answerable,
503
- degraded: result.degraded,
504
- waited: true,
505
- userReturned: result.userReturned,
506
- };
482
+ return { requestId: receipt.request_id, collapseKey, devices: answerable };
507
483
  }
508
- 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) {
509
491
  const deadline = ctx.now() + timeoutSeconds * 1000;
510
492
  let degraded = false;
511
493
  let firstPoll = true;
@@ -513,20 +495,31 @@ async function waitForReplyWhileAway(ctx, requestId, timeoutSeconds) {
513
495
  if (ctx.config.require_idle.value) {
514
496
  const idle = ctx.idleSeconds();
515
497
  if (idle !== null && idle < ctx.config.away_after_seconds.value) {
516
- return { replies: [], degraded, userReturned: true };
498
+ return { byRequest: new Map(), degraded, userReturned: true };
517
499
  }
518
500
  }
519
501
  const remainingMs = Math.max(0, deadline - ctx.now());
520
502
  if (!firstPoll && remainingMs === 0) {
521
- return { replies: [], degraded, userReturned: false };
503
+ return { byRequest: new Map(), degraded, userReturned: false };
522
504
  }
523
505
  firstPoll = false;
524
506
  const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
525
- const result = await ctx.waitForFirstReply(requestId, pollSeconds);
526
- degraded ||= result.degraded === true;
527
- if (result.replies.length > 0) {
528
- 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);
529
520
  }
521
+ if (byRequest.size > 0)
522
+ return { byRequest, degraded, userReturned: false };
530
523
  }
531
524
  }
532
525
  /**
@@ -765,55 +758,132 @@ const LATE_PROMPT_POLL_SECONDS = 3;
765
758
  const MAX_CONTINUATION_COUNT = 3;
766
759
  /** One bounded recovery poll for a question that outlived its original wait. */
767
760
  async function pollPendingReply(ctx, pending, timeoutSeconds) {
768
- if (pending.request_id === undefined)
769
- return { reply: null, degraded: false, failed: false };
761
+ if (pending.request_id === undefined) {
762
+ return { reply: null, replies: [], degraded: false, failed: false };
763
+ }
770
764
  try {
771
765
  const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
772
766
  return {
773
- reply: result.replies[0] ?? null,
767
+ reply: result.replies.at(-1) ?? null,
768
+ replies: result.replies,
774
769
  degraded: result.degraded === true,
775
770
  failed: false,
776
771
  };
777
772
  }
778
773
  catch {
779
- return { reply: null, degraded: true, failed: true };
774
+ return { reply: null, replies: [], degraded: true, failed: true };
775
+ }
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
+ }
802
+ /** The question set this pending record pushes, however it was registered. */
803
+ function pendingQuestions(pending) {
804
+ return pending.questions ?? [{ id: 'q1', text: pending.question }];
805
+ }
806
+ /** Did any question in the set offer choices? Decides how replies combine. */
807
+ function pendingHasChoices(pending) {
808
+ return pendingQuestions(pending).some((question) => question.choices !== undefined);
809
+ }
810
+ /**
811
+ * The answer as the agent should read it. For a question with choices the
812
+ * latest reply IS the answer — an earlier conflicting one was corrected by
813
+ * it. Free-text answers can arrive in parts, and every part reaches the
814
+ * agent in the order it was written, so it can tell expansion from
815
+ * correction itself.
816
+ */
817
+ function answerContext(replies, hadChoices) {
818
+ const latest = replies.at(-1);
819
+ if (latest === undefined)
820
+ return 'Notifai — no answer was recorded.';
821
+ if (replies.length === 1 || hadChoices) {
822
+ return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`;
780
823
  }
824
+ const parts = replies.map((reply) => `"${reply.text}"`).join(', then ');
825
+ return (`Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
826
+ `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
827
+ 'Continue with that answer.');
781
828
  }
782
- function answerContext(reply) {
783
- return `Notifai — the user answered from ${reply.device_name}: "${reply.text}". Continue with that answer.`;
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}`;
784
851
  }
785
- function userPromptAnswerOutput(reply) {
852
+ function userPromptAnswerOutput(context) {
786
853
  return JSON.stringify({
787
854
  hookSpecificOutput: {
788
855
  hookEventName: 'UserPromptSubmit',
789
- additionalContext: answerContext(reply),
856
+ additionalContext: context,
790
857
  },
791
858
  });
792
859
  }
793
- function stopAnswerOutput(reply) {
794
- return JSON.stringify({ decision: 'block', reason: answerContext(reply) });
860
+ function stopAnswerOutput(context) {
861
+ return JSON.stringify({ decision: 'block', reason: context });
795
862
  }
796
863
  /**
797
- * Close, truthfully retire, and forget one answered pending question without
798
- * dropping any other retirement debt. Stop answers also open one bounded
799
- * 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.
800
868
  */
801
- async function finishPendingAnswer(ctx, envelope, sessionId, pending, reply, continuation) {
802
- 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);
803
873
  updateSessionState(sessionId, ctx.env, (current) => {
804
874
  const retiring = [...(current.retiring ?? [])];
805
- if (retirement !== null) {
875
+ for (const retirement of retirements) {
806
876
  const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id);
807
877
  if (existing < 0)
808
878
  retiring.push(retirement);
809
879
  else
810
880
  retiring[existing] = retirement;
811
881
  }
812
- const samePending = current.pending?.question === pending.question &&
813
- current.pending?.asked_at === pending.asked_at &&
814
- current.pending?.request_id === pending.request_id;
882
+ const remaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
815
883
  const next = { ...current, retiring };
816
- if (samePending)
884
+ if (remaining.length > 0)
885
+ next.pending = remaining;
886
+ else
817
887
  delete next.pending;
818
888
  if (continuation) {
819
889
  next.continuation = {
@@ -843,20 +913,19 @@ export async function handleUserPromptSubmit(ctx, envelope) {
843
913
  if (!sessionId)
844
914
  return { notes };
845
915
  const state = readSessionState(sessionId, ctx.env);
846
- const pending = state.pending;
847
- if (pending?.request_id !== undefined) {
848
- const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS);
849
- if (late.reply !== null) {
850
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false);
851
- if (envelope.cwd !== undefined && ctx.harness !== undefined) {
852
- 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}`);
853
925
  }
854
- await drainOrphanRetirements(ctx, ctx.env, ctx.now());
855
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
856
- return { stdout: userPromptAnswerOutput(late.reply), notes };
857
926
  }
858
- if (late.failed || late.degraded) {
859
- 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');
860
929
  }
861
930
  }
862
931
  // Park before dropping `pending`. If the process dies between these writes,
@@ -864,10 +933,10 @@ export async function handleUserPromptSubmit(ctx, envelope) {
864
933
  // the gap after erasing the only request/collapse/device identifiers.
865
934
  updateSessionState(sessionId, ctx.env, (current) => {
866
935
  const retiring = [...(current.retiring ?? [])];
867
- if (current.pending !== undefined) {
868
- const retirement = retiringQuestion(current.pending, 'answered_elsewhere');
936
+ for (const entry of pendingList(current)) {
937
+ const retirement = retiringQuestion(entry, 'answered_elsewhere');
869
938
  if (retirement !== null &&
870
- !retiring.some((entry) => entry.request_id === retirement.request_id)) {
939
+ !retiring.some((parked) => parked.request_id === retirement.request_id)) {
871
940
  retiring.push(retirement);
872
941
  }
873
942
  }
@@ -885,6 +954,11 @@ export async function handleUserPromptSubmit(ctx, envelope) {
885
954
  if (swept.length > 0) {
886
955
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
887
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
+ }
888
962
  return { notes };
889
963
  }
890
964
  // ---------------------------------------------------------------------------
@@ -919,22 +993,36 @@ export async function handleStop(ctx, envelope) {
919
993
  notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
920
994
  }
921
995
  const state = readSessionState(sessionId, ctx.env);
922
- const pending = state.pending;
923
- if (!pending)
996
+ const pending = pendingList(state);
997
+ if (pending.length === 0)
924
998
  return { notes };
925
- if (pending.request_id !== undefined) {
926
- const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS);
927
- if (late.reply !== null) {
928
- await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true);
929
- notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
930
- return { stdout: stopAnswerOutput(late.reply), 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
+ };
931
1014
  }
932
- // Already live on the user's devices from an earlier Stop; asking twice for
933
- // one question is the nagging failure this feature exists to avoid.
934
- notes.push(late.failed || late.degraded
935
- ? `already asked (${pending.request_id}); could not check whether its answer arrived`
936
- : `already asked (${pending.request_id}); waiting for that answer`);
937
- 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.
938
1026
  }
939
1027
  // A Stop answer may immediately produce a legitimate follow-up question.
940
1028
  // Allow that new generation, but never re-run an old pending question and
@@ -942,8 +1030,7 @@ export async function handleStop(ctx, envelope) {
942
1030
  if (envelope.stop_hook_active === true) {
943
1031
  const continuation = state.continuation;
944
1032
  const isNew = continuation !== undefined &&
945
- pending.asked_at !== undefined &&
946
- pending.asked_at > continuation.answered_at;
1033
+ unasked.some((entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at);
947
1034
  if (!isNew) {
948
1035
  notes.push('already continuing from an answer; not asking again this turn');
949
1036
  return { notes };
@@ -969,91 +1056,125 @@ export async function handleStop(ctx, envelope) {
969
1056
  return { notes };
970
1057
  }
971
1058
  try {
972
- return await escalate(ctx, envelope, sessionId, pending, notes);
1059
+ return await escalate(ctx, envelope, sessionId, unasked, live, notes);
973
1060
  }
974
1061
  finally {
975
1062
  releaseQuestionPush(sessionId, ctx.env);
976
1063
  }
977
1064
  }
978
1065
  /** The escalation itself, split out so the claim is released on every path. */
979
- async function escalate(ctx, envelope, sessionId, pending, notes) {
980
- // Away right now, but the question still owes the user its terminal-first
981
- // window before anything reaches their devices.
982
- 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);
983
1072
  if (grace === 'user-returned') {
984
- 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');
985
1074
  return { notes };
986
1075
  }
987
1076
  if (grace === 'no-signal') {
988
1077
  notes.push('no idle signal on this machine; asking now rather than holding the terminal');
989
1078
  }
990
- const asked = await askAndWait(ctx, {
991
- title: ctx.config.project.value === null
992
- ? 'Question'
993
- : `Question · ${ctx.config.project.value}`,
994
- body: pending.question,
995
- ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
996
- event: 'agent_question',
997
- session: sessionLabel(ctx, envelope),
998
- // Outlives the block, and stays open on purpose: the answer is still
999
- // useful to the next turn, which collects it with `notifai replies`.
1000
- windowSeconds: QUESTION_WINDOW_SECONDS,
1001
- onSubmitted: (live) => {
1002
- const livePending = {
1003
- ...pending,
1004
- request_id: live.requestId,
1005
- collapse_key: live.collapseKey,
1006
- device_ids: live.devices,
1007
- };
1008
- updateSessionState(sessionId, ctx.env, (current) => {
1009
- const stillCurrent = current.pending?.question === pending.question &&
1010
- current.pending?.asked_at === pending.asked_at &&
1011
- current.pending?.request_id === undefined;
1012
- if (stillCurrent)
1013
- return { ...current, pending: livePending };
1014
- // A newer `notifai ask` won while this submit was in flight. Keep it,
1015
- // but now that request identifiers exist, preserve the old delivered
1016
- // question as collectable/retirable instead of overwriting either one.
1017
- const retirement = retiringQuestion(livePending, 'superseded');
1018
- const retiring = [...(current.retiring ?? [])];
1019
- if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
1020
- retiring.push(retirement);
1021
- }
1022
- return { ...current, retiring };
1023
- });
1024
- },
1025
- });
1026
- if ('error' in asked) {
1027
- 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`);
1028
1141
  return { notes };
1029
1142
  }
1030
- if (!asked.reply) {
1031
- // Keep the pending record so a returning user's UserPromptSubmit can retire
1032
- // the notification that is still live on their devices. `request_id` being
1033
- // set is also what stops the next Stop pushing the same question again.
1034
- if (!asked.waited) {
1035
- notes.push(`question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`);
1036
- }
1037
- else if (asked.userReturned) {
1038
- notes.push(`you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
1039
- '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');
1040
1154
  }
1041
1155
  else {
1042
- notes.push(asked.degraded
1043
- ? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
1044
- : `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');
1045
1159
  }
1046
1160
  return { notes };
1047
1161
  }
1048
- await finishPendingAnswer(ctx, envelope, sessionId, {
1049
- ...pending,
1050
- request_id: asked.requestId,
1051
- collapse_key: asked.collapseKey,
1052
- device_ids: asked.devices,
1053
- }, asked.reply, true);
1054
- 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
+ }
1055
1176
  return {
1056
- stdout: stopAnswerOutput(asked.reply),
1177
+ stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1057
1178
  notes,
1058
1179
  };
1059
1180
  }
@@ -1077,10 +1198,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1077
1198
  clearMatchingProjectSession(envelope.cwd, env, sessionId);
1078
1199
  const state = readSessionState(sessionId, env);
1079
1200
  const orphans = [...(state.retiring ?? [])];
1080
- const pending = state.pending;
1081
- if (pending !== undefined) {
1201
+ for (const entry of pendingList(state)) {
1082
1202
  try {
1083
- const orphan = retiringQuestion(pending, 'expired');
1203
+ const orphan = retiringQuestion(entry, 'expired');
1084
1204
  if (orphan !== null)
1085
1205
  orphans.push(orphan);
1086
1206
  }
@@ -1103,39 +1223,48 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1103
1223
  // ask — register a question for the turn's end
1104
1224
  // ---------------------------------------------------------------------------
1105
1225
  /**
1106
- * One session holds one live question, so registering a second one ends the
1107
- * first.
1108
- *
1109
- * This used to replace `pending` wholesale, which silently discarded the
1110
- * `request_id` and `collapse_key` of a question already delivered to the user's
1111
- * devices. Nothing else knows those ids, so the notification became
1112
- * unretirable — it sat on the lock screen for ever asking a question no answer
1113
- * 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).
1114
1240
  *
1115
- * Supersession is keyed on the session, not the project: several agents may be
1116
- * running in one project at once, and one agent's new question killing another
1117
- * 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.
1118
1243
  */
1119
1244
  export function registerQuestion(sessionId, env, question, now = Date.now()) {
1245
+ let full = false;
1120
1246
  updateSessionState(sessionId, env, (state) => {
1121
- const retiring = [...(state.retiring ?? [])];
1122
- if (state.pending !== undefined) {
1123
- const retirement = retiringQuestion(state.pending, 'superseded');
1124
- if (retirement !== null &&
1125
- !retiring.some((entry) => entry.request_id === retirement.request_id)) {
1126
- retiring.push(retirement);
1127
- }
1247
+ const pending = pendingList(state);
1248
+ if (pending.length >= MAX_PENDING_QUESTIONS) {
1249
+ full = true;
1250
+ return state;
1128
1251
  }
1129
1252
  return {
1130
1253
  ...state,
1131
- ...(retiring.length > 0 ? { retiring } : {}),
1132
- pending: {
1133
- asked_at: now,
1134
- ...question,
1135
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1136
- },
1254
+ pending: [
1255
+ ...pending,
1256
+ {
1257
+ asked_at: now,
1258
+ ...question,
1259
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1260
+ },
1261
+ ],
1137
1262
  };
1138
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
+ }
1139
1268
  }
1140
1269
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
1141
1270
  export function parseHookInput(raw) {