@raidiant/notifai 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.d.ts +33 -3
- package/dist/commands.js +283 -59
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +274 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/hooks.d.ts +31 -19
- package/dist/hooks.js +277 -189
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/main.js +106 -17
- package/dist/main.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +105 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +2 -1
- package/src/commands.test.ts +153 -11
- package/src/commands.ts +328 -60
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +321 -0
- package/src/hooks.test.ts +214 -81
- package/src/hooks.ts +335 -224
- package/src/interactive.ts +692 -0
- package/src/main.ts +107 -16
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +86 -0
- package/src/ui/config-view.ts +135 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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:
|
|
856
|
+
additionalContext: context,
|
|
825
857
|
},
|
|
826
858
|
});
|
|
827
859
|
}
|
|
828
|
-
function stopAnswerOutput(
|
|
829
|
-
return JSON.stringify({ decision: 'block', reason:
|
|
860
|
+
function stopAnswerOutput(context) {
|
|
861
|
+
return JSON.stringify({ decision: 'block', reason: context });
|
|
830
862
|
}
|
|
831
863
|
/**
|
|
832
|
-
* Close, truthfully retire, and forget
|
|
833
|
-
* dropping any other retirement debt
|
|
834
|
-
*
|
|
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
|
|
837
|
-
const
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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 (
|
|
894
|
-
notes.push('could not check
|
|
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
|
-
|
|
903
|
-
const retirement = retiringQuestion(
|
|
936
|
+
for (const entry of pendingList(current)) {
|
|
937
|
+
const retirement = retiringQuestion(entry, 'answered_elsewhere');
|
|
904
938
|
if (retirement !== null &&
|
|
905
|
-
!retiring.some((
|
|
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
|
|
958
|
-
if (
|
|
996
|
+
const pending = pendingList(state);
|
|
997
|
+
if (pending.length === 0)
|
|
959
998
|
return { notes };
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1015
|
-
// Away right now, but the
|
|
1016
|
-
// window before anything reaches their devices
|
|
1017
|
-
|
|
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
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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(
|
|
1084
|
-
?
|
|
1085
|
-
:
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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(
|
|
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
|
|
1122
|
-
if (pending !== undefined) {
|
|
1201
|
+
for (const entry of pendingList(state)) {
|
|
1123
1202
|
try {
|
|
1124
|
-
const orphan = retiringQuestion(
|
|
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
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
*
|
|
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
|
-
*
|
|
1157
|
-
* running in one project at once, and
|
|
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
|
|
1163
|
-
if (
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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) {
|