@raidiant/notifai 8.0.0 → 9.1.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 +48 -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/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- 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-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- 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 -2
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- 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 +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +83 -7
- package/dist/hooks.js +288 -90
- 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 +1 -1
- 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 +2 -13
- 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 +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +11 -0
- package/dist/session-activation.js +46 -0
- package/dist/session-activation.js.map +1 -0
- 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 +3 -3
- 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/command-session.ts +38 -0
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +70 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +778 -166
- package/src/hooks.ts +368 -129
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- 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
|
|
@@ -108,7 +115,11 @@ export function readSessionState(sessionId, env) {
|
|
|
108
115
|
return {};
|
|
109
116
|
try {
|
|
110
117
|
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
111
|
-
|
|
118
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
119
|
+
return {};
|
|
120
|
+
const state = { ...parsed };
|
|
121
|
+
delete state.session_id;
|
|
122
|
+
return state;
|
|
112
123
|
}
|
|
113
124
|
catch {
|
|
114
125
|
// A corrupt marker must not wedge the harness; treat it as "no evidence",
|
|
@@ -116,9 +127,50 @@ export function readSessionState(sessionId, env) {
|
|
|
116
127
|
return {};
|
|
117
128
|
}
|
|
118
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a stable question or request id to the one exact session that owns it.
|
|
132
|
+
*
|
|
133
|
+
* This deliberately searches lifecycle state, not checkout pointers: question
|
|
134
|
+
* identity is global on this machine, while a linked worktree is only where a
|
|
135
|
+
* command happened to run. Ambiguous or corrupt matches fail closed.
|
|
136
|
+
*/
|
|
137
|
+
export function findOwningSession(id, env) {
|
|
138
|
+
const directory = path.join(stateDir(env), 'sessions');
|
|
139
|
+
if (!existsSync(directory))
|
|
140
|
+
return { sessionId: null, ambiguous: false };
|
|
141
|
+
const matches = [];
|
|
142
|
+
for (const name of readdirSync(directory)) {
|
|
143
|
+
if (!name.endsWith('.json'))
|
|
144
|
+
continue;
|
|
145
|
+
let persisted = null;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(readFileSync(path.join(directory, name), 'utf8'));
|
|
148
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
149
|
+
persisted = parsed;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Corrupt lifecycle state is not identity evidence.
|
|
154
|
+
}
|
|
155
|
+
if (persisted === null || typeof persisted.session_id !== 'string')
|
|
156
|
+
continue;
|
|
157
|
+
const sessionId = persisted.session_id;
|
|
158
|
+
const state = persisted;
|
|
159
|
+
const pendingMatch = pendingList(state).some((entry) => entry.question_id === id || entry.request_id === id);
|
|
160
|
+
const retiringMatch = (state.retiring ?? []).some((entry) => entry.request_id === id);
|
|
161
|
+
const acknowledgementMatch = (state.acknowledgement_due ?? []).some((entry) => entry.request_id === id);
|
|
162
|
+
const acceptedMatch = state.accepted?.answers.some(({ pending }) => pending.question_id === id || pending.request_id === id) ?? false;
|
|
163
|
+
if (pendingMatch || retiringMatch || acknowledgementMatch || acceptedMatch) {
|
|
164
|
+
matches.push(sessionId);
|
|
165
|
+
if (matches.length > 1)
|
|
166
|
+
return { sessionId: null, ambiguous: true };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { sessionId: matches[0] ?? null, ambiguous: false };
|
|
170
|
+
}
|
|
119
171
|
export function writeSessionState(sessionId, env, state) {
|
|
120
172
|
const file = sessionStatePath(sessionId, env);
|
|
121
|
-
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state));
|
|
173
|
+
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state));
|
|
122
174
|
}
|
|
123
175
|
export function clearSessionState(sessionId, env) {
|
|
124
176
|
const file = sessionStatePath(sessionId, env);
|
|
@@ -129,14 +181,93 @@ export function clearSessionState(sessionId, env) {
|
|
|
129
181
|
rmSync(sessionConfigPath(sessionId, env), { force: true });
|
|
130
182
|
});
|
|
131
183
|
}
|
|
132
|
-
function writeSessionStateUnlocked(file, state) {
|
|
133
|
-
atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
184
|
+
function writeSessionStateUnlocked(file, sessionId, state) {
|
|
185
|
+
atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`);
|
|
134
186
|
}
|
|
135
187
|
function updateSessionState(sessionId, env, update) {
|
|
136
188
|
const file = sessionStatePath(sessionId, env);
|
|
137
189
|
return withFileLock(`${file}.lock`, () => {
|
|
138
190
|
const next = update(readSessionState(sessionId, env));
|
|
139
|
-
writeSessionStateUnlocked(file, next);
|
|
191
|
+
writeSessionStateUnlocked(file, sessionId, next);
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
|
|
196
|
+
export function markSessionActivation(sessionId, env, now, ownerPid, harness, cwd) {
|
|
197
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
198
|
+
...current,
|
|
199
|
+
...(harness === undefined ? {} : { harness }),
|
|
200
|
+
...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
|
|
201
|
+
activation_context_emitted_at: now,
|
|
202
|
+
activation_context_owner_pid: ownerPid,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Atomically own first-prompt activation only when no lifecycle hook did.
|
|
207
|
+
*
|
|
208
|
+
* Some managed hosts preserve UserPromptSubmit while regenerating away newer
|
|
209
|
+
* SessionStart definitions. This is a compatibility seam, not the primary
|
|
210
|
+
* lifecycle: one session gets at most one model-visible activation from it.
|
|
211
|
+
*/
|
|
212
|
+
export function claimPromptActivation(sessionId, env, now, ownerPid, harness, cwd) {
|
|
213
|
+
let claimed = false;
|
|
214
|
+
updateSessionState(sessionId, env, (current) => {
|
|
215
|
+
if (current.activation_context_emitted_at !== undefined &&
|
|
216
|
+
current.activation_context_owner_pid === ownerPid) {
|
|
217
|
+
return current;
|
|
218
|
+
}
|
|
219
|
+
claimed = true;
|
|
220
|
+
return {
|
|
221
|
+
...current,
|
|
222
|
+
...(harness === undefined ? {} : { harness }),
|
|
223
|
+
...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
|
|
224
|
+
activation_context_emitted_at: now,
|
|
225
|
+
activation_context_owner_pid: ownerPid,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
return claimed;
|
|
229
|
+
}
|
|
230
|
+
/** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
|
|
231
|
+
export function claimCursorStopActivation(sessionId, env, now) {
|
|
232
|
+
const claimGuardMs = 30_000;
|
|
233
|
+
let claimed = false;
|
|
234
|
+
updateSessionState(sessionId, env, (current) => {
|
|
235
|
+
if (current.cursor_activation_confirmed_at !== undefined)
|
|
236
|
+
return current;
|
|
237
|
+
if (current.cursor_activation_claimed_at !== undefined &&
|
|
238
|
+
now - current.cursor_activation_claimed_at < claimGuardMs) {
|
|
239
|
+
return current;
|
|
240
|
+
}
|
|
241
|
+
claimed = true;
|
|
242
|
+
return { ...current, cursor_activation_claimed_at: now };
|
|
243
|
+
});
|
|
244
|
+
return claimed;
|
|
245
|
+
}
|
|
246
|
+
/** Cursor's loop_count proves the previous Stop follow-up reached the host. */
|
|
247
|
+
export function confirmCursorStopActivation(sessionId, env, now) {
|
|
248
|
+
let owned = false;
|
|
249
|
+
updateSessionState(sessionId, env, (current) => {
|
|
250
|
+
if (current.cursor_activation_confirmed_at !== undefined) {
|
|
251
|
+
owned = true;
|
|
252
|
+
return current;
|
|
253
|
+
}
|
|
254
|
+
if (current.cursor_activation_claimed_at === undefined)
|
|
255
|
+
return current;
|
|
256
|
+
owned = true;
|
|
257
|
+
return { ...current, cursor_activation_confirmed_at: now };
|
|
258
|
+
});
|
|
259
|
+
return owned;
|
|
260
|
+
}
|
|
261
|
+
/** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
|
|
262
|
+
export function resetCursorStopActivation(sessionId, env) {
|
|
263
|
+
updateSessionState(sessionId, env, (current) => {
|
|
264
|
+
if (current.cursor_activation_claimed_at === undefined &&
|
|
265
|
+
current.cursor_activation_confirmed_at === undefined) {
|
|
266
|
+
return current;
|
|
267
|
+
}
|
|
268
|
+
const next = { ...current };
|
|
269
|
+
delete next.cursor_activation_claimed_at;
|
|
270
|
+
delete next.cursor_activation_confirmed_at;
|
|
140
271
|
return next;
|
|
141
272
|
});
|
|
142
273
|
}
|
|
@@ -527,7 +658,6 @@ async function prepareQuestionSubmission(ctx, options) {
|
|
|
527
658
|
const build = buildDraft(ctx.config, {
|
|
528
659
|
title: options.title,
|
|
529
660
|
body: options.body,
|
|
530
|
-
event: options.event,
|
|
531
661
|
lifecycle: { tier: 'needs_you' },
|
|
532
662
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
533
663
|
...(options.media !== undefined ? { media: options.media } : {}),
|
|
@@ -573,6 +703,9 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
|
|
|
573
703
|
let degraded = false;
|
|
574
704
|
let firstPoll = true;
|
|
575
705
|
const permanentFailures = new Map();
|
|
706
|
+
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
707
|
+
return { byRequest: new Map(), degraded: false, permanentFailures };
|
|
708
|
+
}
|
|
576
709
|
for (;;) {
|
|
577
710
|
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
578
711
|
if (!firstPoll && remainingMs === 0) {
|
|
@@ -657,45 +790,20 @@ async function closeQuietly(ctx, requestId) {
|
|
|
657
790
|
await finalizeReplies(ctx, requestId);
|
|
658
791
|
}
|
|
659
792
|
/**
|
|
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.
|
|
793
|
+
* Close the reply window so the server can mint the retirement push. Client
|
|
794
|
+
* submissions cannot carry `question_retired`; that event is reserved for
|
|
795
|
+
* server-origin state syncs.
|
|
666
796
|
*/
|
|
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
|
-
}
|
|
797
|
+
async function proveRetirement(ctx, requestId) {
|
|
798
|
+
const response = await finalizeReplies(ctx, requestId);
|
|
799
|
+
const proven = response !== null;
|
|
800
|
+
ctx.log?.info('hook.retirement', {
|
|
801
|
+
request_id: requestId,
|
|
802
|
+
attempted: true,
|
|
803
|
+
proven,
|
|
804
|
+
});
|
|
805
|
+
return proven;
|
|
688
806
|
}
|
|
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
807
|
/**
|
|
700
808
|
* Park a delivered question for retirement. Called at the moment we learn it is
|
|
701
809
|
* dead, which is frequently not a moment we can reach the network.
|
|
@@ -749,11 +857,36 @@ function retiringQuestion(pending, state, cwd) {
|
|
|
749
857
|
state,
|
|
750
858
|
};
|
|
751
859
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
860
|
+
const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay']);
|
|
861
|
+
function normalizePrompt(value) {
|
|
862
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
863
|
+
}
|
|
864
|
+
function pendingChoiceLabels(pending) {
|
|
865
|
+
const fromQuestions = (pending.questions ?? []).flatMap((question) => (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]));
|
|
866
|
+
return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0);
|
|
867
|
+
}
|
|
868
|
+
function pendingHasMatchingChoice(pending, normalizedPrompt) {
|
|
869
|
+
return pendingChoiceLabels(pending).includes(normalizedPrompt);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Which outstanding questions this prompt plausibly answers.
|
|
873
|
+
*
|
|
874
|
+
* A unique closed-choice match is enough. A short confirmation such as "done"
|
|
875
|
+
* matches only when exactly one question is outstanding. Long unrelated
|
|
876
|
+
* prompts leave every question open for the reply window.
|
|
877
|
+
*/
|
|
878
|
+
export function pendingAnsweredByPrompt(prompt, pending) {
|
|
879
|
+
const normalized = normalizePrompt(prompt ?? '');
|
|
880
|
+
if (normalized.length === 0 || pending.length === 0)
|
|
881
|
+
return [];
|
|
882
|
+
const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized));
|
|
883
|
+
if (labelled.length === 1)
|
|
884
|
+
return labelled;
|
|
885
|
+
if (labelled.length > 1)
|
|
886
|
+
return [];
|
|
887
|
+
if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized))
|
|
888
|
+
return [...pending];
|
|
889
|
+
return [];
|
|
757
890
|
}
|
|
758
891
|
/**
|
|
759
892
|
* Send every parked retirement, and forget the ones that landed. A failure
|
|
@@ -766,9 +899,8 @@ export async function drainRetirements(ctx, sessionId, env) {
|
|
|
766
899
|
return [];
|
|
767
900
|
const retired = [];
|
|
768
901
|
for (const entry of queue) {
|
|
769
|
-
await
|
|
770
|
-
|
|
771
|
-
if (sent) {
|
|
902
|
+
const proven = await proveRetirement(ctx, entry.request_id);
|
|
903
|
+
if (proven) {
|
|
772
904
|
retired.push(entry.request_id);
|
|
773
905
|
// Persist each success before touching the next entry. A later corrupt
|
|
774
906
|
// record or interrupted process must not resurrect work already done.
|
|
@@ -837,9 +969,8 @@ export async function drainOrphanRetirements(ctx, env, now) {
|
|
|
837
969
|
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
838
970
|
continue;
|
|
839
971
|
}
|
|
840
|
-
await
|
|
841
|
-
|
|
842
|
-
if (sent) {
|
|
972
|
+
const proven = await proveRetirement(ctx, entry.request_id);
|
|
973
|
+
if (proven) {
|
|
843
974
|
done.push(entry.request_id);
|
|
844
975
|
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
845
976
|
}
|
|
@@ -893,10 +1024,15 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
|
893
1024
|
}
|
|
894
1025
|
/** Close several windows concurrently without confusing failure with silence. */
|
|
895
1026
|
async function finalizePendings(ctx, pending) {
|
|
896
|
-
return Promise.all(pending.map(async (entry) =>
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1027
|
+
return Promise.all(pending.map(async (entry) => {
|
|
1028
|
+
const response = await finalizeReplies(ctx, entry.request_id);
|
|
1029
|
+
ctx.log?.info('hook.retirement', {
|
|
1030
|
+
request_id: entry.request_id,
|
|
1031
|
+
attempted: true,
|
|
1032
|
+
proven: response !== null,
|
|
1033
|
+
});
|
|
1034
|
+
return { pending: entry, response };
|
|
1035
|
+
}));
|
|
900
1036
|
}
|
|
901
1037
|
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
902
1038
|
function finalizedAnswer(finalized) {
|
|
@@ -920,7 +1056,7 @@ function reportAnswer(ctx, notes, answered, late) {
|
|
|
920
1056
|
...(late ? { late: true } : {}),
|
|
921
1057
|
request_id: answered.pending.request_id,
|
|
922
1058
|
device: answered.reply.device_name,
|
|
923
|
-
|
|
1059
|
+
text_chars: answered.reply.text.length,
|
|
924
1060
|
});
|
|
925
1061
|
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
|
|
926
1062
|
}
|
|
@@ -964,7 +1100,7 @@ function isSamePending(a, b) {
|
|
|
964
1100
|
function isTerminalDraftRejection(err) {
|
|
965
1101
|
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
|
|
966
1102
|
}
|
|
967
|
-
function dropPendingQuestion(sessionId, env, entry) {
|
|
1103
|
+
export function dropPendingQuestion(sessionId, env, entry) {
|
|
968
1104
|
updateSessionState(sessionId, env, (current) => {
|
|
969
1105
|
const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
|
|
970
1106
|
const next = { ...current };
|
|
@@ -975,6 +1111,28 @@ function dropPendingQuestion(sessionId, env, entry) {
|
|
|
975
1111
|
return next;
|
|
976
1112
|
});
|
|
977
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Drop registrations that never reached a device, so a later Stop cannot push
|
|
1116
|
+
* them. A retirement push would be noise: there is nothing on any device to
|
|
1117
|
+
* withdraw. Returns the withdrawn entries in registration order.
|
|
1118
|
+
*/
|
|
1119
|
+
export function withdrawUnpushedQuestions(sessionId, env) {
|
|
1120
|
+
let withdrawn = [];
|
|
1121
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1122
|
+
const pending = pendingList(current);
|
|
1123
|
+
withdrawn = pending.filter((entry) => entry.request_id === undefined);
|
|
1124
|
+
if (withdrawn.length === 0)
|
|
1125
|
+
return current;
|
|
1126
|
+
const remaining = pending.filter((entry) => entry.request_id !== undefined);
|
|
1127
|
+
const next = { ...current };
|
|
1128
|
+
if (remaining.length > 0)
|
|
1129
|
+
next.pending = remaining;
|
|
1130
|
+
else
|
|
1131
|
+
delete next.pending;
|
|
1132
|
+
return next;
|
|
1133
|
+
});
|
|
1134
|
+
return withdrawn;
|
|
1135
|
+
}
|
|
978
1136
|
function clearFrozenSubmission(sessionId, env, entry) {
|
|
979
1137
|
updateSessionState(sessionId, env, (current) => {
|
|
980
1138
|
const list = pendingList(current);
|
|
@@ -1308,10 +1466,9 @@ async function retirePendings(ctx, envelope, sessionId, entries, state) {
|
|
|
1308
1466
|
// UserPromptSubmit — the user is at the keyboard
|
|
1309
1467
|
// ---------------------------------------------------------------------------
|
|
1310
1468
|
/**
|
|
1311
|
-
* Records presence
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
* user being here is what makes the notification noise.
|
|
1469
|
+
* Records presence. Retires a live question only when this prompt plausibly
|
|
1470
|
+
* answers it. Typing in the terminal is not by itself a resolution: the User
|
|
1471
|
+
* may be answering a different question on a device.
|
|
1315
1472
|
*/
|
|
1316
1473
|
export async function handleUserPromptSubmit(ctx, envelope) {
|
|
1317
1474
|
const notes = [];
|
|
@@ -1322,6 +1479,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1322
1479
|
if (state.accepted !== undefined) {
|
|
1323
1480
|
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1324
1481
|
...current,
|
|
1482
|
+
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
1325
1483
|
last_prompt_at: ctx.now(),
|
|
1326
1484
|
}));
|
|
1327
1485
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
@@ -1365,24 +1523,42 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1365
1523
|
if (permanentFailure !== null)
|
|
1366
1524
|
notes.push(permanentFailure);
|
|
1367
1525
|
}
|
|
1526
|
+
const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state));
|
|
1527
|
+
for (const entry of matched) {
|
|
1528
|
+
ctx.log?.info('hook.retirement', {
|
|
1529
|
+
request_id: entry.request_id,
|
|
1530
|
+
question_id: entry.question_id,
|
|
1531
|
+
reason: 'prompt-matched',
|
|
1532
|
+
attempted: entry.request_id !== undefined,
|
|
1533
|
+
proven: false,
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1368
1536
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
1369
1537
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
1370
1538
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
1371
1539
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1372
1540
|
const retiring = [...(current.retiring ?? [])];
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1541
|
+
const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current));
|
|
1542
|
+
const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
|
|
1543
|
+
for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
|
|
1375
1544
|
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
|
|
1376
1545
|
if (retirement !== null &&
|
|
1377
1546
|
!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
1378
1547
|
retiring.push(retirement);
|
|
1379
1548
|
}
|
|
1380
1549
|
}
|
|
1550
|
+
const unasked = unmatched.filter((entry) => entry.request_id === undefined);
|
|
1551
|
+
const stillLive = unmatched.filter((entry) => entry.request_id !== undefined);
|
|
1552
|
+
const remaining = [...stillLive, ...unasked];
|
|
1381
1553
|
// Start from the whole document so fields introduced by a newer CLI remain
|
|
1382
1554
|
// attached while this older writer applies the prompt transition it knows.
|
|
1383
|
-
const next = {
|
|
1384
|
-
|
|
1385
|
-
|
|
1555
|
+
const next = {
|
|
1556
|
+
...current,
|
|
1557
|
+
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
1558
|
+
last_prompt_at: ctx.now(),
|
|
1559
|
+
};
|
|
1560
|
+
if (remaining.length > 0)
|
|
1561
|
+
next.pending = remaining;
|
|
1386
1562
|
else
|
|
1387
1563
|
delete next.pending;
|
|
1388
1564
|
if (retiring.length > 0)
|
|
@@ -1454,6 +1630,7 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
1454
1630
|
// from the prompt-only state that previously looked healthy.
|
|
1455
1631
|
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1456
1632
|
...current,
|
|
1633
|
+
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
1457
1634
|
last_stop_at: ctx.now(),
|
|
1458
1635
|
}));
|
|
1459
1636
|
// The waiter, not its host, owns this lease. It spans grace, submission,
|
|
@@ -1661,6 +1838,10 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
|
|
|
1661
1838
|
outcome.log = delivered.log;
|
|
1662
1839
|
return outcome;
|
|
1663
1840
|
}
|
|
1841
|
+
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
1842
|
+
function ownerLeaseActive(entry, now) {
|
|
1843
|
+
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
|
|
1844
|
+
}
|
|
1664
1845
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1665
1846
|
async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
|
|
1666
1847
|
const live = pending.filter((entry) => entry.request_id !== undefined);
|
|
@@ -1715,18 +1896,34 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1715
1896
|
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1716
1897
|
}
|
|
1717
1898
|
const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
|
|
1718
|
-
|
|
1899
|
+
// A spent owner has already used its waiter ceiling. Passing those
|
|
1900
|
+
// questions back into `escalate` starts a fresh ceiling, holds the
|
|
1901
|
+
// per-session claim for another 3300s, and is why a later `ask` never
|
|
1902
|
+
// reached a device after a degraded waiter (the next Stop never ran, or
|
|
1903
|
+
// ran as claimed-elsewhere). Late answers still ride the short poll above
|
|
1904
|
+
// and every later turn. Independent questions whose lease is still running
|
|
1905
|
+
// keep their successor wait.
|
|
1906
|
+
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
|
|
1907
|
+
liveToEscalate = stillOwned;
|
|
1719
1908
|
if (unasked.length === 0) {
|
|
1720
1909
|
if (recoverableLive.length === 0)
|
|
1721
1910
|
return { notes };
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1911
|
+
if (stillOwned.length === 0) {
|
|
1912
|
+
const requestIdSummary = summarizeRequestIds(recoverableLive);
|
|
1913
|
+
ctx.log?.info('hook.answer', {
|
|
1914
|
+
answered: false,
|
|
1915
|
+
stage: 'queued',
|
|
1916
|
+
request_ids: requestIdSummary.ids,
|
|
1917
|
+
owner_spent: true,
|
|
1918
|
+
});
|
|
1919
|
+
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`);
|
|
1920
|
+
return { notes };
|
|
1921
|
+
}
|
|
1922
|
+
return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
|
|
1727
1923
|
}
|
|
1728
1924
|
// Questions registered after the earlier push still owe the user their
|
|
1729
|
-
// notification; fall through and escalate just those
|
|
1925
|
+
// notification; fall through and escalate just those, plus any live
|
|
1926
|
+
// question whose owner lease is still running.
|
|
1730
1927
|
}
|
|
1731
1928
|
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
1732
1929
|
// Allow that new generation, but never re-run an old pending question and
|
|
@@ -1830,7 +2027,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1830
2027
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1831
2028
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1832
2029
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
1833
|
-
event: 'agent_question',
|
|
1834
2030
|
windowSeconds: replyWindowSeconds,
|
|
1835
2031
|
ownerDeadlineAt,
|
|
1836
2032
|
});
|
|
@@ -1909,7 +2105,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1909
2105
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1910
2106
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1911
2107
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
1912
|
-
event: 'agent_question',
|
|
1913
2108
|
windowSeconds: replyWindowSeconds,
|
|
1914
2109
|
ownerDeadlineAt,
|
|
1915
2110
|
});
|
|
@@ -1976,7 +2171,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1976
2171
|
request_id: intent.request_id,
|
|
1977
2172
|
devices: intent.device_ids.length,
|
|
1978
2173
|
questions: questions.length,
|
|
1979
|
-
|
|
2174
|
+
text_chars: questions[0].text.length,
|
|
1980
2175
|
});
|
|
1981
2176
|
}
|
|
1982
2177
|
const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
@@ -1995,14 +2190,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1995
2190
|
// stale about eight minutes later and retired, so the answer given over
|
|
1996
2191
|
// lunch met a closed question.
|
|
1997
2192
|
//
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
// this process waits.
|
|
2193
|
+
// The reply window is how long the server accepts an answer. This
|
|
2194
|
+
// process still clamps its own wait to `hardDeadlineAt`. Recording the
|
|
2195
|
+
// owner lease separately is what stops the *next* Stop from treating
|
|
2196
|
+
// the remaining window as a fresh waiter ceiling.
|
|
2003
2197
|
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2004
2198
|
? committedReplyDeadline
|
|
2005
2199
|
: intent.owner_deadline_at,
|
|
2200
|
+
owner_deadline_at: intent.owner_deadline_at,
|
|
2006
2201
|
};
|
|
2007
2202
|
if (intent.draft.source === undefined)
|
|
2008
2203
|
delete live.source;
|
|
@@ -2065,7 +2260,8 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2065
2260
|
return { notes };
|
|
2066
2261
|
// Phase two: one wait across everything live, old and new alike.
|
|
2067
2262
|
const ownerDeadline = Math.min(ceilingAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
|
|
2068
|
-
const
|
|
2263
|
+
const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000;
|
|
2264
|
+
const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000));
|
|
2069
2265
|
const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
|
|
2070
2266
|
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
|
|
2071
2267
|
if (permanentFailure !== null)
|
|
@@ -2087,7 +2283,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2087
2283
|
// ends still retires its questions — that is what the session-end hook is
|
|
2088
2284
|
// for, and it is the honest place for it.
|
|
2089
2285
|
const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
|
|
2090
|
-
entry.reply_deadline_at <=
|
|
2286
|
+
entry.reply_deadline_at <= ownerDeadline ||
|
|
2091
2287
|
// A permanent rejection is the server saying this question can never be
|
|
2092
2288
|
// answered, whatever its window says. Leaving it open would strand a
|
|
2093
2289
|
// dead question on the user's devices.
|
|
@@ -2126,9 +2322,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
2126
2322
|
permanent_failures: waited.permanentFailures.size,
|
|
2127
2323
|
waited_seconds: timeoutSeconds,
|
|
2128
2324
|
});
|
|
2129
|
-
if (waited.permanentFailures.size === 0) {
|
|
2325
|
+
if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
2130
2326
|
notes.push(waited.degraded
|
|
2131
|
-
? 'could not reach the server before the owner deadline;
|
|
2327
|
+
? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
|
|
2132
2328
|
: 'no answer in time; the question expired with its continuation owner');
|
|
2133
2329
|
}
|
|
2134
2330
|
return { notes };
|
|
@@ -2260,6 +2456,7 @@ export const MAX_LIVE_QUESTIONS = 10;
|
|
|
2260
2456
|
*/
|
|
2261
2457
|
export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
2262
2458
|
let full = null;
|
|
2459
|
+
const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
|
|
2263
2460
|
updateSessionState(sessionId, env, (state) => {
|
|
2264
2461
|
const pending = pendingList(state);
|
|
2265
2462
|
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
@@ -2276,9 +2473,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
|
2276
2473
|
pending: [
|
|
2277
2474
|
...pending,
|
|
2278
2475
|
{
|
|
2279
|
-
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
2280
2476
|
asked_at: now,
|
|
2281
2477
|
...question,
|
|
2478
|
+
question_id: questionId,
|
|
2282
2479
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
2283
2480
|
},
|
|
2284
2481
|
],
|
|
@@ -2290,8 +2487,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
|
2290
2487
|
}
|
|
2291
2488
|
if (full === 'live') {
|
|
2292
2489
|
throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
2293
|
-
'Retire the ones you no longer need with `notifai close <
|
|
2490
|
+
'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
|
|
2294
2491
|
}
|
|
2492
|
+
return questionId;
|
|
2295
2493
|
}
|
|
2296
2494
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|
|
2297
2495
|
export function parseHookInput(raw) {
|