@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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
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
|
|
447
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
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
|
-
|
|
783
|
-
|
|
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(
|
|
852
|
+
function userPromptAnswerOutput(context) {
|
|
786
853
|
return JSON.stringify({
|
|
787
854
|
hookSpecificOutput: {
|
|
788
855
|
hookEventName: 'UserPromptSubmit',
|
|
789
|
-
additionalContext:
|
|
856
|
+
additionalContext: context,
|
|
790
857
|
},
|
|
791
858
|
});
|
|
792
859
|
}
|
|
793
|
-
function stopAnswerOutput(
|
|
794
|
-
return JSON.stringify({ decision: 'block', reason:
|
|
860
|
+
function stopAnswerOutput(context) {
|
|
861
|
+
return JSON.stringify({ decision: 'block', reason: context });
|
|
795
862
|
}
|
|
796
863
|
/**
|
|
797
|
-
* Close, truthfully retire, and forget
|
|
798
|
-
* dropping any other retirement debt
|
|
799
|
-
*
|
|
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
|
|
802
|
-
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);
|
|
803
873
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
804
874
|
const retiring = [...(current.retiring ?? [])];
|
|
805
|
-
|
|
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
|
|
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 (
|
|
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
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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 (
|
|
859
|
-
notes.push('could not check
|
|
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
|
-
|
|
868
|
-
const retirement = retiringQuestion(
|
|
936
|
+
for (const entry of pendingList(current)) {
|
|
937
|
+
const retirement = retiringQuestion(entry, 'answered_elsewhere');
|
|
869
938
|
if (retirement !== null &&
|
|
870
|
-
!retiring.some((
|
|
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
|
|
923
|
-
if (
|
|
996
|
+
const pending = pendingList(state);
|
|
997
|
+
if (pending.length === 0)
|
|
924
998
|
return { notes };
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
980
|
-
// Away right now, but the
|
|
981
|
-
// window before anything reaches their devices
|
|
982
|
-
|
|
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
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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(
|
|
1043
|
-
?
|
|
1044
|
-
:
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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(
|
|
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
|
|
1081
|
-
if (pending !== undefined) {
|
|
1201
|
+
for (const entry of pendingList(state)) {
|
|
1082
1202
|
try {
|
|
1083
|
-
const orphan = retiringQuestion(
|
|
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
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
*
|
|
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
|
-
*
|
|
1116
|
-
* running in one project at once, and
|
|
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
|
|
1122
|
-
if (
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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) {
|