@raidiant/notifai 7.0.2 → 9.0.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/CHANGELOG.md +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/dist/hooks.js
CHANGED
|
@@ -74,6 +74,13 @@ export function waiterCeilingSeconds(detached) {
|
|
|
74
74
|
* server go on taking an answer after the waiter that owns it has returned.
|
|
75
75
|
*/
|
|
76
76
|
export const MIN_REPLY_WINDOW_SECONDS = 60;
|
|
77
|
+
/**
|
|
78
|
+
* HTTP budget reserved for the waiter's final reply fetch and close fence.
|
|
79
|
+
* Taken from the owner deadline rather than added after it, so the hook does
|
|
80
|
+
* not block past its ceiling. A near-zero remainder used to produce a
|
|
81
|
+
* misleading 0s timeout and an unproven retirement.
|
|
82
|
+
*/
|
|
83
|
+
export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3;
|
|
77
84
|
/**
|
|
78
85
|
* The terminal-first wait: the question sits in the terminal for
|
|
79
86
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -140,6 +147,81 @@ function updateSessionState(sessionId, env, update) {
|
|
|
140
147
|
return next;
|
|
141
148
|
});
|
|
142
149
|
}
|
|
150
|
+
/** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
|
|
151
|
+
export function markSessionActivation(sessionId, env, now, ownerPid) {
|
|
152
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
153
|
+
...current,
|
|
154
|
+
activation_context_emitted_at: now,
|
|
155
|
+
activation_context_owner_pid: ownerPid,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Atomically own first-prompt activation only when no lifecycle hook did.
|
|
160
|
+
*
|
|
161
|
+
* Some managed hosts preserve UserPromptSubmit while regenerating away newer
|
|
162
|
+
* SessionStart definitions. This is a compatibility seam, not the primary
|
|
163
|
+
* lifecycle: one session gets at most one model-visible activation from it.
|
|
164
|
+
*/
|
|
165
|
+
export function claimPromptActivation(sessionId, env, now, ownerPid) {
|
|
166
|
+
let claimed = false;
|
|
167
|
+
updateSessionState(sessionId, env, (current) => {
|
|
168
|
+
if (current.activation_context_emitted_at !== undefined &&
|
|
169
|
+
current.activation_context_owner_pid === ownerPid) {
|
|
170
|
+
return current;
|
|
171
|
+
}
|
|
172
|
+
claimed = true;
|
|
173
|
+
return {
|
|
174
|
+
...current,
|
|
175
|
+
activation_context_emitted_at: now,
|
|
176
|
+
activation_context_owner_pid: ownerPid,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
return claimed;
|
|
180
|
+
}
|
|
181
|
+
/** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
|
|
182
|
+
export function claimCursorStopActivation(sessionId, env, now) {
|
|
183
|
+
const claimGuardMs = 30_000;
|
|
184
|
+
let claimed = false;
|
|
185
|
+
updateSessionState(sessionId, env, (current) => {
|
|
186
|
+
if (current.cursor_activation_confirmed_at !== undefined)
|
|
187
|
+
return current;
|
|
188
|
+
if (current.cursor_activation_claimed_at !== undefined &&
|
|
189
|
+
now - current.cursor_activation_claimed_at < claimGuardMs) {
|
|
190
|
+
return current;
|
|
191
|
+
}
|
|
192
|
+
claimed = true;
|
|
193
|
+
return { ...current, cursor_activation_claimed_at: now };
|
|
194
|
+
});
|
|
195
|
+
return claimed;
|
|
196
|
+
}
|
|
197
|
+
/** Cursor's loop_count proves the previous Stop follow-up reached the host. */
|
|
198
|
+
export function confirmCursorStopActivation(sessionId, env, now) {
|
|
199
|
+
let owned = false;
|
|
200
|
+
updateSessionState(sessionId, env, (current) => {
|
|
201
|
+
if (current.cursor_activation_confirmed_at !== undefined) {
|
|
202
|
+
owned = true;
|
|
203
|
+
return current;
|
|
204
|
+
}
|
|
205
|
+
if (current.cursor_activation_claimed_at === undefined)
|
|
206
|
+
return current;
|
|
207
|
+
owned = true;
|
|
208
|
+
return { ...current, cursor_activation_confirmed_at: now };
|
|
209
|
+
});
|
|
210
|
+
return owned;
|
|
211
|
+
}
|
|
212
|
+
/** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
|
|
213
|
+
export function resetCursorStopActivation(sessionId, env) {
|
|
214
|
+
updateSessionState(sessionId, env, (current) => {
|
|
215
|
+
if (current.cursor_activation_claimed_at === undefined &&
|
|
216
|
+
current.cursor_activation_confirmed_at === undefined) {
|
|
217
|
+
return current;
|
|
218
|
+
}
|
|
219
|
+
const next = { ...current };
|
|
220
|
+
delete next.cursor_activation_claimed_at;
|
|
221
|
+
delete next.cursor_activation_confirmed_at;
|
|
222
|
+
return next;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
143
225
|
/**
|
|
144
226
|
* Session state a crashed harness left behind.
|
|
145
227
|
*
|
|
@@ -331,8 +413,9 @@ function claimPath(sessionId, env) {
|
|
|
331
413
|
/**
|
|
332
414
|
* A question is stored so a later hook can push it, and it reaches us from a
|
|
333
415
|
* shell command, so its size is whatever the agent typed. The push itself is
|
|
334
|
-
* bounded by the 4096-byte
|
|
335
|
-
* meantime, and keeps one runaway agent from writing
|
|
416
|
+
* bounded by the current 4096-byte push-provider envelopes; this bounds what
|
|
417
|
+
* sits on disk in the meantime, and keeps one runaway agent from writing
|
|
418
|
+
* megabytes per session.
|
|
336
419
|
*/
|
|
337
420
|
const MAX_STORED_QUESTION_CHARS = 2000;
|
|
338
421
|
function storedProjectSessionPointer(candidate) {
|
|
@@ -526,7 +609,6 @@ async function prepareQuestionSubmission(ctx, options) {
|
|
|
526
609
|
const build = buildDraft(ctx.config, {
|
|
527
610
|
title: options.title,
|
|
528
611
|
body: options.body,
|
|
529
|
-
event: options.event,
|
|
530
612
|
lifecycle: { tier: 'needs_you' },
|
|
531
613
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
532
614
|
...(options.media !== undefined ? { media: options.media } : {}),
|
|
@@ -572,6 +654,9 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
|
|
|
572
654
|
let degraded = false;
|
|
573
655
|
let firstPoll = true;
|
|
574
656
|
const permanentFailures = new Map();
|
|
657
|
+
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
658
|
+
return { byRequest: new Map(), degraded: false, permanentFailures };
|
|
659
|
+
}
|
|
575
660
|
for (;;) {
|
|
576
661
|
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
577
662
|
if (!firstPoll && remainingMs === 0) {
|
|
@@ -623,15 +708,14 @@ function permanentReplyFailureNote(failures) {
|
|
|
623
708
|
const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ');
|
|
624
709
|
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`;
|
|
625
710
|
}
|
|
626
|
-
/**
|
|
627
|
-
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
628
|
-
* macOS app register reply categories and submit answers directly.
|
|
629
|
-
*/
|
|
711
|
+
/** Healthy Companion devices that advertise the current answer job. */
|
|
630
712
|
async function answerableDevices(ctx) {
|
|
631
713
|
const configured = ctx.config.devices.value;
|
|
632
714
|
const { devices } = await ctx.client.listDevices();
|
|
633
715
|
return devices
|
|
634
|
-
.filter((device) => (device.platform === 'ios' ||
|
|
716
|
+
.filter((device) => (device.platform === 'ios' ||
|
|
717
|
+
device.platform === 'macos' ||
|
|
718
|
+
device.platform === 'android') &&
|
|
635
719
|
device.registration_healthy &&
|
|
636
720
|
device.capabilities?.includes('answer') === true &&
|
|
637
721
|
device.derived_status !== 'must_update')
|
|
@@ -657,45 +741,20 @@ async function closeQuietly(ctx, requestId) {
|
|
|
657
741
|
await finalizeReplies(ctx, requestId);
|
|
658
742
|
}
|
|
659
743
|
/**
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* delivered question and marks it done. If the app cannot run, the stale
|
|
664
|
-
* question simply waits until next open, which beats a tombstone banner
|
|
665
|
-
* announcing what the user just did.
|
|
744
|
+
* Close the reply window so the server can mint the retirement push. Client
|
|
745
|
+
* submissions cannot carry `question_retired`; that event is reserved for
|
|
746
|
+
* server-origin state syncs.
|
|
666
747
|
*/
|
|
667
|
-
async function
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
level: 'passive',
|
|
677
|
-
}, source === undefined ? {} : { source });
|
|
678
|
-
if (!build.ok)
|
|
679
|
-
return false;
|
|
680
|
-
try {
|
|
681
|
-
await ctx.client.submit({ idempotency_key: `retire-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
|
|
682
|
-
return true;
|
|
683
|
-
}
|
|
684
|
-
catch {
|
|
685
|
-
// Same reasoning as closeQuietly: the window is already closed server-side.
|
|
686
|
-
return false;
|
|
687
|
-
}
|
|
748
|
+
async function proveRetirement(ctx, requestId) {
|
|
749
|
+
const response = await finalizeReplies(ctx, requestId);
|
|
750
|
+
const proven = response !== null;
|
|
751
|
+
ctx.log?.info('hook.retirement', {
|
|
752
|
+
request_id: requestId,
|
|
753
|
+
attempted: true,
|
|
754
|
+
proven,
|
|
755
|
+
});
|
|
756
|
+
return proven;
|
|
688
757
|
}
|
|
689
|
-
/**
|
|
690
|
-
* The title a retirement carries. It is never rendered as a banner — the push
|
|
691
|
-
* is `content-available` only — but it is what a companion with no matching
|
|
692
|
-
* history entry has to fall back on, and it is what shows in server-side logs.
|
|
693
|
-
*/
|
|
694
|
-
const RETIREMENT_TITLES = {
|
|
695
|
-
answered: 'Answered',
|
|
696
|
-
answered_elsewhere: 'Answered in the terminal',
|
|
697
|
-
expired: 'Question expired',
|
|
698
|
-
};
|
|
699
758
|
/**
|
|
700
759
|
* Park a delivered question for retirement. Called at the moment we learn it is
|
|
701
760
|
* dead, which is frequently not a moment we can reach the network.
|
|
@@ -749,11 +808,36 @@ function retiringQuestion(pending, state, cwd) {
|
|
|
749
808
|
state,
|
|
750
809
|
};
|
|
751
810
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
811
|
+
const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay']);
|
|
812
|
+
function normalizePrompt(value) {
|
|
813
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
814
|
+
}
|
|
815
|
+
function pendingChoiceLabels(pending) {
|
|
816
|
+
const fromQuestions = (pending.questions ?? []).flatMap((question) => (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]));
|
|
817
|
+
return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0);
|
|
818
|
+
}
|
|
819
|
+
function pendingHasMatchingChoice(pending, normalizedPrompt) {
|
|
820
|
+
return pendingChoiceLabels(pending).includes(normalizedPrompt);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Which outstanding questions this prompt plausibly answers.
|
|
824
|
+
*
|
|
825
|
+
* A unique closed-choice match is enough. A short confirmation such as "done"
|
|
826
|
+
* matches only when exactly one question is outstanding. Long unrelated
|
|
827
|
+
* prompts leave every question open for the reply window.
|
|
828
|
+
*/
|
|
829
|
+
export function pendingAnsweredByPrompt(prompt, pending) {
|
|
830
|
+
const normalized = normalizePrompt(prompt ?? '');
|
|
831
|
+
if (normalized.length === 0 || pending.length === 0)
|
|
832
|
+
return [];
|
|
833
|
+
const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized));
|
|
834
|
+
if (labelled.length === 1)
|
|
835
|
+
return labelled;
|
|
836
|
+
if (labelled.length > 1)
|
|
837
|
+
return [];
|
|
838
|
+
if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized))
|
|
839
|
+
return [...pending];
|
|
840
|
+
return [];
|
|
757
841
|
}
|
|
758
842
|
/**
|
|
759
843
|
* Send every parked retirement, and forget the ones that landed. A failure
|
|
@@ -766,9 +850,8 @@ export async function drainRetirements(ctx, sessionId, env) {
|
|
|
766
850
|
return [];
|
|
767
851
|
const retired = [];
|
|
768
852
|
for (const entry of queue) {
|
|
769
|
-
await
|
|
770
|
-
|
|
771
|
-
if (sent) {
|
|
853
|
+
const proven = await proveRetirement(ctx, entry.request_id);
|
|
854
|
+
if (proven) {
|
|
772
855
|
retired.push(entry.request_id);
|
|
773
856
|
// Persist each success before touching the next entry. A later corrupt
|
|
774
857
|
// record or interrupted process must not resurrect work already done.
|
|
@@ -837,9 +920,8 @@ export async function drainOrphanRetirements(ctx, env, now) {
|
|
|
837
920
|
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
838
921
|
continue;
|
|
839
922
|
}
|
|
840
|
-
await
|
|
841
|
-
|
|
842
|
-
if (sent) {
|
|
923
|
+
const proven = await proveRetirement(ctx, entry.request_id);
|
|
924
|
+
if (proven) {
|
|
843
925
|
done.push(entry.request_id);
|
|
844
926
|
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
845
927
|
}
|
|
@@ -893,10 +975,15 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
|
893
975
|
}
|
|
894
976
|
/** Close several windows concurrently without confusing failure with silence. */
|
|
895
977
|
async function finalizePendings(ctx, pending) {
|
|
896
|
-
return Promise.all(pending.map(async (entry) =>
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
978
|
+
return Promise.all(pending.map(async (entry) => {
|
|
979
|
+
const response = await finalizeReplies(ctx, entry.request_id);
|
|
980
|
+
ctx.log?.info('hook.retirement', {
|
|
981
|
+
request_id: entry.request_id,
|
|
982
|
+
attempted: true,
|
|
983
|
+
proven: response !== null,
|
|
984
|
+
});
|
|
985
|
+
return { pending: entry, response };
|
|
986
|
+
}));
|
|
900
987
|
}
|
|
901
988
|
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
902
989
|
function finalizedAnswer(finalized) {
|
|
@@ -920,7 +1007,7 @@ function reportAnswer(ctx, notes, answered, late) {
|
|
|
920
1007
|
...(late ? { late: true } : {}),
|
|
921
1008
|
request_id: answered.pending.request_id,
|
|
922
1009
|
device: answered.reply.device_name,
|
|
923
|
-
|
|
1010
|
+
text_chars: answered.reply.text.length,
|
|
924
1011
|
});
|
|
925
1012
|
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
|
|
926
1013
|
}
|
|
@@ -964,7 +1051,7 @@ function isSamePending(a, b) {
|
|
|
964
1051
|
function isTerminalDraftRejection(err) {
|
|
965
1052
|
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
|
|
966
1053
|
}
|
|
967
|
-
function dropPendingQuestion(sessionId, env, entry) {
|
|
1054
|
+
export function dropPendingQuestion(sessionId, env, entry) {
|
|
968
1055
|
updateSessionState(sessionId, env, (current) => {
|
|
969
1056
|
const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
|
|
970
1057
|
const next = { ...current };
|
|
@@ -975,6 +1062,28 @@ function dropPendingQuestion(sessionId, env, entry) {
|
|
|
975
1062
|
return next;
|
|
976
1063
|
});
|
|
977
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Drop registrations that never reached a device, so a later Stop cannot push
|
|
1067
|
+
* them. A retirement push would be noise: there is nothing on any device to
|
|
1068
|
+
* withdraw. Returns the withdrawn entries in registration order.
|
|
1069
|
+
*/
|
|
1070
|
+
export function withdrawUnpushedQuestions(sessionId, env) {
|
|
1071
|
+
let withdrawn = [];
|
|
1072
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1073
|
+
const pending = pendingList(current);
|
|
1074
|
+
withdrawn = pending.filter((entry) => entry.request_id === undefined);
|
|
1075
|
+
if (withdrawn.length === 0)
|
|
1076
|
+
return current;
|
|
1077
|
+
const remaining = pending.filter((entry) => entry.request_id !== undefined);
|
|
1078
|
+
const next = { ...current };
|
|
1079
|
+
if (remaining.length > 0)
|
|
1080
|
+
next.pending = remaining;
|
|
1081
|
+
else
|
|
1082
|
+
delete next.pending;
|
|
1083
|
+
return next;
|
|
1084
|
+
});
|
|
1085
|
+
return withdrawn;
|
|
1086
|
+
}
|
|
978
1087
|
function clearFrozenSubmission(sessionId, env, entry) {
|
|
979
1088
|
updateSessionState(sessionId, env, (current) => {
|
|
980
1089
|
const list = pendingList(current);
|
|
@@ -1308,10 +1417,9 @@ async function retirePendings(ctx, envelope, sessionId, entries, state) {
|
|
|
1308
1417
|
// UserPromptSubmit — the user is at the keyboard
|
|
1309
1418
|
// ---------------------------------------------------------------------------
|
|
1310
1419
|
/**
|
|
1311
|
-
* Records presence
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
* user being here is what makes the notification noise.
|
|
1420
|
+
* Records presence. Retires a live question only when this prompt plausibly
|
|
1421
|
+
* answers it. Typing in the terminal is not by itself a resolution: the User
|
|
1422
|
+
* may be answering a different question on a device.
|
|
1315
1423
|
*/
|
|
1316
1424
|
export async function handleUserPromptSubmit(ctx, envelope) {
|
|
1317
1425
|
const notes = [];
|
|
@@ -1365,24 +1473,41 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1365
1473
|
if (permanentFailure !== null)
|
|
1366
1474
|
notes.push(permanentFailure);
|
|
1367
1475
|
}
|
|
1476
|
+
const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state));
|
|
1477
|
+
for (const entry of matched) {
|
|
1478
|
+
ctx.log?.info('hook.retirement', {
|
|
1479
|
+
request_id: entry.request_id,
|
|
1480
|
+
question_id: entry.question_id,
|
|
1481
|
+
reason: 'prompt-matched',
|
|
1482
|
+
attempted: entry.request_id !== undefined,
|
|
1483
|
+
proven: false,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1368
1486
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
1369
1487
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
1370
1488
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
1371
1489
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1372
1490
|
const retiring = [...(current.retiring ?? [])];
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1491
|
+
const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current));
|
|
1492
|
+
const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
|
|
1493
|
+
for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
|
|
1375
1494
|
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
|
|
1376
1495
|
if (retirement !== null &&
|
|
1377
1496
|
!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
1378
1497
|
retiring.push(retirement);
|
|
1379
1498
|
}
|
|
1380
1499
|
}
|
|
1500
|
+
const unasked = unmatched.filter((entry) => entry.request_id === undefined);
|
|
1501
|
+
const stillLive = unmatched.filter((entry) => entry.request_id !== undefined);
|
|
1502
|
+
const remaining = [...stillLive, ...unasked];
|
|
1381
1503
|
// Start from the whole document so fields introduced by a newer CLI remain
|
|
1382
1504
|
// attached while this older writer applies the prompt transition it knows.
|
|
1383
|
-
const next = {
|
|
1384
|
-
|
|
1385
|
-
|
|
1505
|
+
const next = {
|
|
1506
|
+
...current,
|
|
1507
|
+
last_prompt_at: ctx.now(),
|
|
1508
|
+
};
|
|
1509
|
+
if (remaining.length > 0)
|
|
1510
|
+
next.pending = remaining;
|
|
1386
1511
|
else
|
|
1387
1512
|
delete next.pending;
|
|
1388
1513
|
if (retiring.length > 0)
|
|
@@ -1661,6 +1786,10 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
1661
1786
|
outcome.log = delivered.log;
|
|
1662
1787
|
return outcome;
|
|
1663
1788
|
}
|
|
1789
|
+
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
1790
|
+
function ownerLeaseActive(entry, now) {
|
|
1791
|
+
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
|
|
1792
|
+
}
|
|
1664
1793
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1665
1794
|
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
|
|
1666
1795
|
const live = pending.filter((entry) => entry.request_id !== undefined);
|
|
@@ -1715,18 +1844,34 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1715
1844
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1716
1845
|
}
|
|
1717
1846
|
const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
|
|
1718
|
-
|
|
1847
|
+
// A spent owner has already used its waiter ceiling. Passing those
|
|
1848
|
+
// questions back into `escalate` starts a fresh ceiling, holds the
|
|
1849
|
+
// per-session claim for another 3300s, and is why a later `ask` never
|
|
1850
|
+
// reached a device after a degraded waiter (the next Stop never ran, or
|
|
1851
|
+
// ran as claimed-elsewhere). Late answers still ride the short poll above
|
|
1852
|
+
// and every later turn. Independent questions whose lease is still running
|
|
1853
|
+
// keep their successor wait.
|
|
1854
|
+
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
|
|
1855
|
+
liveToEscalate = stillOwned;
|
|
1719
1856
|
if (unasked.length === 0) {
|
|
1720
1857
|
if (recoverableLive.length === 0)
|
|
1721
1858
|
return { notes };
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1859
|
+
if (stillOwned.length === 0) {
|
|
1860
|
+
const requestIdSummary = summarizeRequestIds(recoverableLive);
|
|
1861
|
+
ctx.log?.info('hook.answer', {
|
|
1862
|
+
answered: false,
|
|
1863
|
+
stage: 'queued',
|
|
1864
|
+
request_ids: requestIdSummary.ids,
|
|
1865
|
+
owner_spent: true,
|
|
1866
|
+
});
|
|
1867
|
+
notes.push(`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still answerable; the previous waiter already used its ceiling, so a later turn will collect a late answer rather than waiting again`);
|
|
1868
|
+
return { notes };
|
|
1869
|
+
}
|
|
1870
|
+
return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
|
|
1727
1871
|
}
|
|
1728
1872
|
// Questions registered after the earlier push still owe the user their
|
|
1729
|
-
// notification; fall through and escalate just those
|
|
1873
|
+
// notification; fall through and escalate just those, plus any live
|
|
1874
|
+
// question whose owner lease is still running.
|
|
1730
1875
|
}
|
|
1731
1876
|
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
1732
1877
|
// Allow that new generation, but never re-run an old pending question and
|
|
@@ -1830,7 +1975,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1830
1975
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1831
1976
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1832
1977
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
1833
|
-
event: 'agent_question',
|
|
1834
1978
|
windowSeconds: replyWindowSeconds,
|
|
1835
1979
|
ownerDeadlineAt,
|
|
1836
1980
|
});
|
|
@@ -1909,7 +2053,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1909
2053
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1910
2054
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1911
2055
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
1912
|
-
event: 'agent_question',
|
|
1913
2056
|
windowSeconds: replyWindowSeconds,
|
|
1914
2057
|
ownerDeadlineAt,
|
|
1915
2058
|
});
|
|
@@ -1976,7 +2119,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1976
2119
|
request_id: intent.request_id,
|
|
1977
2120
|
devices: intent.device_ids.length,
|
|
1978
2121
|
questions: questions.length,
|
|
1979
|
-
|
|
2122
|
+
text_chars: questions[0].text.length,
|
|
1980
2123
|
});
|
|
1981
2124
|
}
|
|
1982
2125
|
const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
@@ -1995,14 +2138,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1995
2138
|
// stale about eight minutes later and retired, so the answer given over
|
|
1996
2139
|
// lunch met a closed question.
|
|
1997
2140
|
//
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
// this process waits.
|
|
2141
|
+
// The reply window is how long the server accepts an answer. This
|
|
2142
|
+
// process still clamps its own wait to `hardDeadlineAt`. Recording the
|
|
2143
|
+
// owner lease separately is what stops the *next* Stop from treating
|
|
2144
|
+
// the remaining window as a fresh waiter ceiling.
|
|
2003
2145
|
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2004
2146
|
? committedReplyDeadline
|
|
2005
2147
|
: intent.owner_deadline_at,
|
|
2148
|
+
owner_deadline_at: intent.owner_deadline_at,
|
|
2006
2149
|
};
|
|
2007
2150
|
if (intent.draft.source === undefined)
|
|
2008
2151
|
delete live.source;
|
|
@@ -2065,7 +2208,8 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2065
2208
|
return { notes };
|
|
2066
2209
|
// Phase two: one wait across everything live, old and new alike.
|
|
2067
2210
|
const ownerDeadline = Math.min(ceilingAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
|
|
2068
|
-
const
|
|
2211
|
+
const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000;
|
|
2212
|
+
const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000));
|
|
2069
2213
|
const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
|
|
2070
2214
|
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
|
|
2071
2215
|
if (permanentFailure !== null)
|
|
@@ -2087,7 +2231,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2087
2231
|
// ends still retires its questions — that is what the session-end hook is
|
|
2088
2232
|
// for, and it is the honest place for it.
|
|
2089
2233
|
const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
|
|
2090
|
-
entry.reply_deadline_at <=
|
|
2234
|
+
entry.reply_deadline_at <= ownerDeadline ||
|
|
2091
2235
|
// A permanent rejection is the server saying this question can never be
|
|
2092
2236
|
// answered, whatever its window says. Leaving it open would strand a
|
|
2093
2237
|
// dead question on the user's devices.
|
|
@@ -2126,9 +2270,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2126
2270
|
permanent_failures: waited.permanentFailures.size,
|
|
2127
2271
|
waited_seconds: timeoutSeconds,
|
|
2128
2272
|
});
|
|
2129
|
-
if (waited.permanentFailures.size === 0) {
|
|
2273
|
+
if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
2130
2274
|
notes.push(waited.degraded
|
|
2131
|
-
? 'could not reach the server before the owner deadline;
|
|
2275
|
+
? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
|
|
2132
2276
|
: 'no answer in time; the question expired with its continuation owner');
|
|
2133
2277
|
}
|
|
2134
2278
|
return { notes };
|
|
@@ -2260,6 +2404,7 @@ export const MAX_LIVE_QUESTIONS = 10;
|
|
|
2260
2404
|
*/
|
|
2261
2405
|
export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
2262
2406
|
let full = null;
|
|
2407
|
+
const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
|
|
2263
2408
|
updateSessionState(sessionId, env, (state) => {
|
|
2264
2409
|
const pending = pendingList(state);
|
|
2265
2410
|
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
@@ -2276,9 +2421,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
|
2276
2421
|
pending: [
|
|
2277
2422
|
...pending,
|
|
2278
2423
|
{
|
|
2279
|
-
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
2280
2424
|
asked_at: now,
|
|
2281
2425
|
...question,
|
|
2426
|
+
question_id: questionId,
|
|
2282
2427
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
2283
2428
|
},
|
|
2284
2429
|
],
|
|
@@ -2290,8 +2435,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
|
2290
2435
|
}
|
|
2291
2436
|
if (full === 'live') {
|
|
2292
2437
|
throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
2293
|
-
'Retire the ones you no longer need with `notifai close <
|
|
2438
|
+
'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
|
|
2294
2439
|
}
|
|
2440
|
+
return questionId;
|
|
2295
2441
|
}
|
|
2296
2442
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|
|
2297
2443
|
export function parseHookInput(raw) {
|