@raidiant/notifai 0.1.7 → 0.1.8
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 +9 -2
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +45 -33
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
package/dist/hooks.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
|
|
5
5
|
import { buildDraft } from './send.js';
|
|
6
|
+
import { atomicWriteFileSync } from './atomic-file.js';
|
|
6
7
|
/**
|
|
7
8
|
* Past this, the question's reply window (3600s) has long expired server-side
|
|
8
9
|
* and the companion shows it as dead on next open anyway; pushing a retirement
|
|
@@ -82,14 +83,84 @@ export function readSessionState(sessionId, env) {
|
|
|
82
83
|
}
|
|
83
84
|
export function writeSessionState(sessionId, env, state) {
|
|
84
85
|
const file = sessionStatePath(sessionId, env);
|
|
85
|
-
|
|
86
|
-
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
86
|
+
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state));
|
|
87
87
|
}
|
|
88
88
|
export function clearSessionState(sessionId, env) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
const file = sessionStatePath(sessionId, env);
|
|
90
|
+
withFileLock(`${file}.lock`, () => {
|
|
91
|
+
rmSync(file, { force: true });
|
|
92
|
+
// The session override lives in a sibling file; leaving it behind meant a
|
|
93
|
+
// later session reusing the id silently inherited `ask_notifications = false`.
|
|
94
|
+
rmSync(sessionConfigPath(sessionId, env), { force: true });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function writeSessionStateUnlocked(file, state) {
|
|
98
|
+
atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
99
|
+
}
|
|
100
|
+
function updateSessionState(sessionId, env, update) {
|
|
101
|
+
const file = sessionStatePath(sessionId, env);
|
|
102
|
+
return withFileLock(`${file}.lock`, () => {
|
|
103
|
+
const next = update(readSessionState(sessionId, env));
|
|
104
|
+
writeSessionStateUnlocked(file, next);
|
|
105
|
+
return next;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Serialize a short read-modify-write without ever exposing partial JSON.
|
|
110
|
+
* A crashed holder is recoverable after 30 seconds; releases compare their
|
|
111
|
+
* random token under the same cooperating lock discipline before unlinking.
|
|
112
|
+
*/
|
|
113
|
+
const FILE_LOCK_STALE_MS = 30_000;
|
|
114
|
+
const FILE_LOCK_WAIT_MS = 1_000;
|
|
115
|
+
const FILE_LOCK_POLL_MS = 5;
|
|
116
|
+
const lockSleep = new Int32Array(new SharedArrayBuffer(4));
|
|
117
|
+
function withFileLock(file, action) {
|
|
118
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
119
|
+
const token = randomBytes(12).toString('base64url');
|
|
120
|
+
const deadline = Date.now() + FILE_LOCK_WAIT_MS;
|
|
121
|
+
for (;;) {
|
|
122
|
+
try {
|
|
123
|
+
const handle = openSync(file, 'wx', 0o600);
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
closeSync(handle);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err.code !== 'EEXIST')
|
|
134
|
+
throw err;
|
|
135
|
+
let stale = false;
|
|
136
|
+
try {
|
|
137
|
+
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
138
|
+
stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
stale = true;
|
|
142
|
+
}
|
|
143
|
+
if (stale)
|
|
144
|
+
rmSync(file, { force: true });
|
|
145
|
+
else if (Date.now() >= deadline)
|
|
146
|
+
throw new Error(`timed out waiting for state lock ${file}`);
|
|
147
|
+
else
|
|
148
|
+
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return action();
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
try {
|
|
156
|
+
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
157
|
+
if (held.token === token)
|
|
158
|
+
rmSync(file, { force: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// A replaced or externally removed lock is no longer ours to release.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
93
164
|
}
|
|
94
165
|
/**
|
|
95
166
|
* Session state a crashed harness left behind.
|
|
@@ -149,50 +220,107 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
|
|
|
149
220
|
* pending question, see no `request_id`, and both escalate: one question, two
|
|
150
221
|
* notifications.
|
|
151
222
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
223
|
+
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
224
|
+
* and the other steps aside. A short guard serializes stale replacement with
|
|
225
|
+
* contenders and releases; random ownership tokens keep an old holder from
|
|
226
|
+
* unlinking its replacement. A claim older than the hook budget is a crashed
|
|
227
|
+
* process rather than a live one and is broken, avoiding permanent suppression.
|
|
157
228
|
*/
|
|
158
229
|
const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000;
|
|
159
|
-
|
|
230
|
+
const heldClaims = new Map();
|
|
231
|
+
export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
|
|
160
232
|
const file = claimPath(sessionId, env);
|
|
233
|
+
const guard = `${file}.guard`;
|
|
234
|
+
const token = randomBytes(12).toString('base64url');
|
|
161
235
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
236
|
+
if (!acquireClaimGuard(guard))
|
|
237
|
+
return false;
|
|
162
238
|
try {
|
|
163
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
|
|
164
|
-
mode: 0o600,
|
|
165
|
-
flag: 'wx',
|
|
166
|
-
});
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
// Held. Break it only if whoever holds it cannot still be running.
|
|
171
|
-
try {
|
|
172
|
-
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
173
|
-
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY;
|
|
174
|
-
if (age >= 0 && age < CLAIM_TTL_MS)
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
// Unreadable or corrupt: treat as abandoned.
|
|
179
|
-
}
|
|
180
|
-
rmSync(file, { force: true });
|
|
181
239
|
try {
|
|
182
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
|
|
240
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
183
241
|
mode: 0o600,
|
|
184
242
|
flag: 'wx',
|
|
185
243
|
});
|
|
244
|
+
heldClaims.set(file, token);
|
|
186
245
|
return true;
|
|
187
246
|
}
|
|
188
247
|
catch {
|
|
189
|
-
//
|
|
190
|
-
|
|
248
|
+
// Held. Break it only if whoever holds it cannot still be running.
|
|
249
|
+
try {
|
|
250
|
+
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
251
|
+
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY;
|
|
252
|
+
if (age >= 0 && age < CLAIM_TTL_MS)
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Unreadable or corrupt: treat as abandoned.
|
|
257
|
+
}
|
|
258
|
+
beforeStaleReplace?.();
|
|
259
|
+
rmSync(file, { force: true });
|
|
260
|
+
try {
|
|
261
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
262
|
+
mode: 0o600,
|
|
263
|
+
flag: 'wx',
|
|
264
|
+
});
|
|
265
|
+
heldClaims.set(file, token);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
191
271
|
}
|
|
192
272
|
}
|
|
273
|
+
finally {
|
|
274
|
+
rmSync(guard, { force: true });
|
|
275
|
+
}
|
|
193
276
|
}
|
|
194
277
|
export function releaseQuestionPush(sessionId, env) {
|
|
195
|
-
|
|
278
|
+
const file = claimPath(sessionId, env);
|
|
279
|
+
const token = heldClaims.get(file);
|
|
280
|
+
if (token === undefined)
|
|
281
|
+
return;
|
|
282
|
+
const guard = `${file}.guard`;
|
|
283
|
+
if (!acquireClaimGuard(guard)) {
|
|
284
|
+
heldClaims.delete(file);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
try {
|
|
289
|
+
const held = JSON.parse(readFileSync(file, 'utf8'));
|
|
290
|
+
if (held.token === token)
|
|
291
|
+
rmSync(file, { force: true });
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Missing or replaced claims are not ours to release.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
heldClaims.delete(file);
|
|
299
|
+
rmSync(guard, { force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** The guard serializes stale replacement and release; recover a crashed guard. */
|
|
303
|
+
function acquireClaimGuard(file) {
|
|
304
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
305
|
+
try {
|
|
306
|
+
const handle = openSync(file, 'wx', 0o600);
|
|
307
|
+
closeSync(handle);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (err.code !== 'EEXIST')
|
|
312
|
+
return false;
|
|
313
|
+
try {
|
|
314
|
+
if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS)
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// A vanished/corrupt guard is safe to retry once.
|
|
319
|
+
}
|
|
320
|
+
rmSync(file, { force: true });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
196
324
|
}
|
|
197
325
|
function claimPath(sessionId, env) {
|
|
198
326
|
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.claim`);
|
|
@@ -205,11 +333,10 @@ function claimPath(sessionId, env) {
|
|
|
205
333
|
*/
|
|
206
334
|
const MAX_STORED_QUESTION_CHARS = 2000;
|
|
207
335
|
/** Records which session is working in a directory, for `notifai ask`. */
|
|
208
|
-
export function writeProjectSession(cwd, env, sessionId, now) {
|
|
336
|
+
export function writeProjectSession(cwd, env, sessionId, now, harness) {
|
|
209
337
|
const file = projectSessionPointerPath(cwd, env);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
mode: 0o600,
|
|
338
|
+
withFileLock(`${file}.lock`, () => {
|
|
339
|
+
atomicWriteFileSync(file, `${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`);
|
|
213
340
|
});
|
|
214
341
|
}
|
|
215
342
|
/**
|
|
@@ -218,6 +345,9 @@ export function writeProjectSession(cwd, env, sessionId, now) {
|
|
|
218
345
|
* into state nothing is watching.
|
|
219
346
|
*/
|
|
220
347
|
export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
348
|
+
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null;
|
|
349
|
+
}
|
|
350
|
+
export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
221
351
|
const file = projectSessionPointerPath(cwd, env);
|
|
222
352
|
if (!existsSync(file))
|
|
223
353
|
return null;
|
|
@@ -225,14 +355,39 @@ export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
|
|
|
225
355
|
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
226
356
|
if (typeof parsed.session_id !== 'string' || parsed.session_id === '')
|
|
227
357
|
return null;
|
|
228
|
-
if (typeof parsed.updated_at
|
|
358
|
+
if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs)
|
|
359
|
+
return null;
|
|
360
|
+
if (!HOOK_HARNESSES.includes(parsed.harness))
|
|
229
361
|
return null;
|
|
230
|
-
|
|
362
|
+
// A pointer is routing evidence only while its session state still exists
|
|
363
|
+
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
364
|
+
// when a crash prevented the pointer file itself from being removed.
|
|
365
|
+
const sessionFile = sessionStatePath(parsed.session_id, env);
|
|
366
|
+
if (!existsSync(sessionFile))
|
|
367
|
+
return null;
|
|
368
|
+
const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
|
369
|
+
if (typeof state !== 'object' || state === null || Array.isArray(state))
|
|
370
|
+
return null;
|
|
371
|
+
return { sessionId: parsed.session_id, harness: parsed.harness };
|
|
231
372
|
}
|
|
232
373
|
catch {
|
|
233
374
|
return null;
|
|
234
375
|
}
|
|
235
376
|
}
|
|
377
|
+
const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'];
|
|
378
|
+
function clearMatchingProjectSession(cwd, env, sessionId) {
|
|
379
|
+
const file = projectSessionPointerPath(cwd, env);
|
|
380
|
+
withFileLock(`${file}.lock`, () => {
|
|
381
|
+
try {
|
|
382
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
383
|
+
if (parsed.session_id === sessionId)
|
|
384
|
+
rmSync(file, { force: true });
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// Missing/corrupt is already not a live pointer.
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
236
391
|
/**
|
|
237
392
|
* Absent means the user is not at this machine's keyboard.
|
|
238
393
|
*
|
|
@@ -283,6 +438,8 @@ export function isUserAway(state, config, now, idleSeconds) {
|
|
|
283
438
|
* than about the user. Matches the project pointer's staleness horizon.
|
|
284
439
|
*/
|
|
285
440
|
const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000;
|
|
441
|
+
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
442
|
+
const REPLY_PRESENCE_POLL_SECONDS = 5;
|
|
286
443
|
/**
|
|
287
444
|
* Push a question and block for the answer.
|
|
288
445
|
*
|
|
@@ -311,7 +468,6 @@ async function askAndWait(ctx, options) {
|
|
|
311
468
|
replyWindow: Math.max(60, options.windowSeconds),
|
|
312
469
|
...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
|
|
313
470
|
collapseKey,
|
|
314
|
-
level: 'time_sensitive',
|
|
315
471
|
});
|
|
316
472
|
if (!build.ok)
|
|
317
473
|
return { error: build.error };
|
|
@@ -325,7 +481,18 @@ async function askAndWait(ctx, options) {
|
|
|
325
481
|
collapseKey,
|
|
326
482
|
devices: answerable,
|
|
327
483
|
});
|
|
328
|
-
|
|
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);
|
|
329
496
|
if (result.replies.length > 0)
|
|
330
497
|
await closeQuietly(ctx, receipt.request_id);
|
|
331
498
|
return {
|
|
@@ -333,9 +500,35 @@ async function askAndWait(ctx, options) {
|
|
|
333
500
|
collapseKey,
|
|
334
501
|
reply: result.replies[0] ?? null,
|
|
335
502
|
devices: answerable,
|
|
336
|
-
degraded: result.degraded
|
|
503
|
+
degraded: result.degraded,
|
|
504
|
+
waited: true,
|
|
505
|
+
userReturned: result.userReturned,
|
|
337
506
|
};
|
|
338
507
|
}
|
|
508
|
+
async function waitForReplyWhileAway(ctx, requestId, timeoutSeconds) {
|
|
509
|
+
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
510
|
+
let degraded = false;
|
|
511
|
+
let firstPoll = true;
|
|
512
|
+
for (;;) {
|
|
513
|
+
if (ctx.config.require_idle.value) {
|
|
514
|
+
const idle = ctx.idleSeconds();
|
|
515
|
+
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
516
|
+
return { replies: [], degraded, userReturned: true };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
520
|
+
if (!firstPoll && remainingMs === 0) {
|
|
521
|
+
return { replies: [], degraded, userReturned: false };
|
|
522
|
+
}
|
|
523
|
+
firstPoll = false;
|
|
524
|
+
const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
|
|
525
|
+
const result = await ctx.waitForFirstReply(requestId, pollSeconds);
|
|
526
|
+
degraded ||= result.degraded === true;
|
|
527
|
+
if (result.replies.length > 0) {
|
|
528
|
+
return { replies: result.replies, degraded, userReturned: false };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
339
532
|
/**
|
|
340
533
|
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
341
534
|
* macOS app register reply categories and submit answers directly.
|
|
@@ -427,13 +620,18 @@ export function parkForRetirement(sessionId, env, pending, state) {
|
|
|
427
620
|
const retirement = retiringQuestion(pending, state);
|
|
428
621
|
if (retirement === null)
|
|
429
622
|
return;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
...
|
|
436
|
-
|
|
623
|
+
updateSessionState(sessionId, env, (current) => {
|
|
624
|
+
const already = current.retiring ?? [];
|
|
625
|
+
const existing = already.findIndex((entry) => entry.request_id === retirement.request_id);
|
|
626
|
+
if (existing < 0)
|
|
627
|
+
return { ...current, retiring: [...already, retirement] };
|
|
628
|
+
const retiring = [...already];
|
|
629
|
+
// An observed answer is final truth and upgrades an earlier supersession
|
|
630
|
+
// parked while the submission callback was racing a newer question.
|
|
631
|
+
if (retirement.state === 'answered' && retiring[existing].state !== 'answered') {
|
|
632
|
+
retiring[existing] = retirement;
|
|
633
|
+
}
|
|
634
|
+
return { ...current, retiring };
|
|
437
635
|
});
|
|
438
636
|
}
|
|
439
637
|
/**
|
|
@@ -477,20 +675,19 @@ export async function drainRetirements(ctx, sessionId, env, session) {
|
|
|
477
675
|
if (queue.length === 0)
|
|
478
676
|
return [];
|
|
479
677
|
const retired = [];
|
|
480
|
-
const remaining = [];
|
|
481
678
|
for (const entry of queue) {
|
|
482
679
|
await closeQuietly(ctx, entry.request_id);
|
|
483
680
|
const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), session);
|
|
484
|
-
if (sent)
|
|
681
|
+
if (sent) {
|
|
485
682
|
retired.push(entry.request_id);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
683
|
+
// Persist each success before touching the next entry. A later corrupt
|
|
684
|
+
// record or interrupted process must not resurrect work already done.
|
|
685
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
686
|
+
...current,
|
|
687
|
+
retiring: (current.retiring ?? []).filter((candidate) => candidate.request_id !== entry.request_id),
|
|
688
|
+
}));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
494
691
|
return retired;
|
|
495
692
|
}
|
|
496
693
|
function orphanQueuePath(env) {
|
|
@@ -509,23 +706,32 @@ function readOrphanQueue(env) {
|
|
|
509
706
|
return [];
|
|
510
707
|
}
|
|
511
708
|
}
|
|
512
|
-
function
|
|
709
|
+
function writeOrphanQueueUnlocked(file, queue) {
|
|
710
|
+
atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`);
|
|
711
|
+
}
|
|
712
|
+
function updateOrphanQueue(env, update) {
|
|
513
713
|
const file = orphanQueuePath(env);
|
|
514
|
-
|
|
515
|
-
|
|
714
|
+
return withFileLock(`${file}.lock`, () => {
|
|
715
|
+
const next = update(readOrphanQueue(env));
|
|
716
|
+
writeOrphanQueueUnlocked(file, next);
|
|
717
|
+
return next;
|
|
718
|
+
});
|
|
516
719
|
}
|
|
517
720
|
/** Move retirements into the global queue; deduped so a retry costs nothing. */
|
|
518
721
|
export function orphanRetirements(env, entries, session, now) {
|
|
519
722
|
if (entries.length === 0)
|
|
520
723
|
return;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
724
|
+
updateOrphanQueue(env, (queue) => {
|
|
725
|
+
const known = new Set(queue.map((entry) => entry.request_id));
|
|
726
|
+
const added = entries
|
|
727
|
+
.filter((entry) => !known.has(entry.request_id))
|
|
728
|
+
.map((entry) => ({
|
|
729
|
+
...entry,
|
|
730
|
+
...(session !== undefined ? { session } : {}),
|
|
731
|
+
enqueued_at: now,
|
|
732
|
+
}));
|
|
733
|
+
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP);
|
|
734
|
+
});
|
|
529
735
|
}
|
|
530
736
|
/**
|
|
531
737
|
* Retire everything a dead session left behind. Failures stay queued for the
|
|
@@ -542,20 +748,86 @@ export async function drainOrphanRetirements(ctx, env, now) {
|
|
|
542
748
|
// and cheap, so treat it as due rather than letting it linger for ever.
|
|
543
749
|
if (age > ORPHAN_TTL_MS) {
|
|
544
750
|
done.push(entry.request_id);
|
|
751
|
+
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
545
752
|
continue;
|
|
546
753
|
}
|
|
547
754
|
await closeQuietly(ctx, entry.request_id);
|
|
548
755
|
const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), entry.session);
|
|
549
|
-
if (sent)
|
|
756
|
+
if (sent) {
|
|
550
757
|
done.push(entry.request_id);
|
|
758
|
+
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
759
|
+
}
|
|
551
760
|
}
|
|
552
|
-
// Re-read: another session's SessionEnd may have queued more while these were
|
|
553
|
-
// in flight, and clobbering its write would recreate the very loss this
|
|
554
|
-
// queue exists to prevent.
|
|
555
|
-
const current = readOrphanQueue(env).filter((entry) => !done.includes(entry.request_id));
|
|
556
|
-
writeOrphanQueue(env, current);
|
|
557
761
|
return done;
|
|
558
762
|
}
|
|
763
|
+
const LATE_STOP_POLL_SECONDS = 4;
|
|
764
|
+
const LATE_PROMPT_POLL_SECONDS = 3;
|
|
765
|
+
const MAX_CONTINUATION_COUNT = 3;
|
|
766
|
+
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
767
|
+
async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
768
|
+
if (pending.request_id === undefined)
|
|
769
|
+
return { reply: null, degraded: false, failed: false };
|
|
770
|
+
try {
|
|
771
|
+
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
|
|
772
|
+
return {
|
|
773
|
+
reply: result.replies[0] ?? null,
|
|
774
|
+
degraded: result.degraded === true,
|
|
775
|
+
failed: false,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return { reply: null, degraded: true, failed: true };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function answerContext(reply) {
|
|
783
|
+
return `Notifai — the user answered from ${reply.device_name}: "${reply.text}". Continue with that answer.`;
|
|
784
|
+
}
|
|
785
|
+
function userPromptAnswerOutput(reply) {
|
|
786
|
+
return JSON.stringify({
|
|
787
|
+
hookSpecificOutput: {
|
|
788
|
+
hookEventName: 'UserPromptSubmit',
|
|
789
|
+
additionalContext: answerContext(reply),
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
function stopAnswerOutput(reply) {
|
|
794
|
+
return JSON.stringify({ decision: 'block', reason: answerContext(reply) });
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Close, truthfully retire, and forget one answered pending question without
|
|
798
|
+
* dropping any other retirement debt. Stop answers also open one bounded
|
|
799
|
+
* continuation generation; UserPromptSubmit answers ride the user's new turn.
|
|
800
|
+
*/
|
|
801
|
+
async function finishPendingAnswer(ctx, envelope, sessionId, pending, reply, continuation) {
|
|
802
|
+
const retirement = retiringQuestion(pending, 'answered');
|
|
803
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
804
|
+
const retiring = [...(current.retiring ?? [])];
|
|
805
|
+
if (retirement !== null) {
|
|
806
|
+
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id);
|
|
807
|
+
if (existing < 0)
|
|
808
|
+
retiring.push(retirement);
|
|
809
|
+
else
|
|
810
|
+
retiring[existing] = retirement;
|
|
811
|
+
}
|
|
812
|
+
const samePending = current.pending?.question === pending.question &&
|
|
813
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
814
|
+
current.pending?.request_id === pending.request_id;
|
|
815
|
+
const next = { ...current, retiring };
|
|
816
|
+
if (samePending)
|
|
817
|
+
delete next.pending;
|
|
818
|
+
if (continuation) {
|
|
819
|
+
next.continuation = {
|
|
820
|
+
answered_at: ctx.now(),
|
|
821
|
+
count: (envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
next.last_prompt_at = ctx.now();
|
|
826
|
+
}
|
|
827
|
+
return next;
|
|
828
|
+
});
|
|
829
|
+
await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
|
|
830
|
+
}
|
|
559
831
|
// ---------------------------------------------------------------------------
|
|
560
832
|
// UserPromptSubmit — the user is at the keyboard
|
|
561
833
|
// ---------------------------------------------------------------------------
|
|
@@ -572,20 +844,41 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
572
844
|
return { notes };
|
|
573
845
|
const state = readSessionState(sessionId, ctx.env);
|
|
574
846
|
const pending = state.pending;
|
|
847
|
+
if (pending?.request_id !== undefined) {
|
|
848
|
+
const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS);
|
|
849
|
+
if (late.reply !== null) {
|
|
850
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false);
|
|
851
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
852
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
853
|
+
}
|
|
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
|
+
}
|
|
858
|
+
if (late.failed || late.degraded) {
|
|
859
|
+
notes.push('could not check the pending question for a late answer before the prompt');
|
|
860
|
+
}
|
|
861
|
+
}
|
|
575
862
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
576
863
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
577
864
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
865
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
866
|
+
const retiring = [...(current.retiring ?? [])];
|
|
867
|
+
if (current.pending !== undefined) {
|
|
868
|
+
const retirement = retiringQuestion(current.pending, 'answered_elsewhere');
|
|
869
|
+
if (retirement !== null &&
|
|
870
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
871
|
+
retiring.push(retirement);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) };
|
|
584
875
|
});
|
|
585
|
-
// The bridge that lets a plain `notifai ask` find
|
|
586
|
-
// shell command gets no hook payload and
|
|
587
|
-
|
|
588
|
-
|
|
876
|
+
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
877
|
+
// session: an agent shell command gets no hook payload, and not every
|
|
878
|
+
// harness exports an id in the same shape.
|
|
879
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
880
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
881
|
+
}
|
|
589
882
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
|
|
590
883
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
591
884
|
const swept = [...retired, ...orphaned];
|
|
@@ -625,22 +918,41 @@ export async function handleStop(ctx, envelope) {
|
|
|
625
918
|
if (swept.length > 0) {
|
|
626
919
|
notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
627
920
|
}
|
|
628
|
-
// The harness sets this when it is already resuming us from a previous Stop
|
|
629
|
-
// decision. Pushing again here is how a question turns into nagging.
|
|
630
|
-
if (envelope.stop_hook_active === true) {
|
|
631
|
-
notes.push('already continuing from an answer; not asking again this turn');
|
|
632
|
-
return { notes };
|
|
633
|
-
}
|
|
634
921
|
const state = readSessionState(sessionId, ctx.env);
|
|
635
922
|
const pending = state.pending;
|
|
636
923
|
if (!pending)
|
|
637
924
|
return { notes };
|
|
638
925
|
if (pending.request_id !== undefined) {
|
|
926
|
+
const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS);
|
|
927
|
+
if (late.reply !== null) {
|
|
928
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true);
|
|
929
|
+
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
|
|
930
|
+
return { stdout: stopAnswerOutput(late.reply), notes };
|
|
931
|
+
}
|
|
639
932
|
// Already live on the user's devices from an earlier Stop; asking twice for
|
|
640
933
|
// one question is the nagging failure this feature exists to avoid.
|
|
641
|
-
notes.push(
|
|
934
|
+
notes.push(late.failed || late.degraded
|
|
935
|
+
? `already asked (${pending.request_id}); could not check whether its answer arrived`
|
|
936
|
+
: `already asked (${pending.request_id}); waiting for that answer`);
|
|
642
937
|
return { notes };
|
|
643
938
|
}
|
|
939
|
+
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
940
|
+
// Allow that new generation, but never re-run an old pending question and
|
|
941
|
+
// never let answer continuations become an unbounded agent loop.
|
|
942
|
+
if (envelope.stop_hook_active === true) {
|
|
943
|
+
const continuation = state.continuation;
|
|
944
|
+
const isNew = continuation !== undefined &&
|
|
945
|
+
pending.asked_at !== undefined &&
|
|
946
|
+
pending.asked_at > continuation.answered_at;
|
|
947
|
+
if (!isNew) {
|
|
948
|
+
notes.push('already continuing from an answer; not asking again this turn');
|
|
949
|
+
return { notes };
|
|
950
|
+
}
|
|
951
|
+
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
952
|
+
notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
|
|
953
|
+
return { notes };
|
|
954
|
+
}
|
|
955
|
+
}
|
|
644
956
|
if (!ctx.config.ask_notifications.value)
|
|
645
957
|
return { notes };
|
|
646
958
|
if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
|
|
@@ -657,14 +969,14 @@ export async function handleStop(ctx, envelope) {
|
|
|
657
969
|
return { notes };
|
|
658
970
|
}
|
|
659
971
|
try {
|
|
660
|
-
return await escalate(ctx, envelope, sessionId,
|
|
972
|
+
return await escalate(ctx, envelope, sessionId, pending, notes);
|
|
661
973
|
}
|
|
662
974
|
finally {
|
|
663
975
|
releaseQuestionPush(sessionId, ctx.env);
|
|
664
976
|
}
|
|
665
977
|
}
|
|
666
978
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
667
|
-
async function escalate(ctx, envelope, sessionId,
|
|
979
|
+
async function escalate(ctx, envelope, sessionId, pending, notes) {
|
|
668
980
|
// Away right now, but the question still owes the user its terminal-first
|
|
669
981
|
// window before anything reaches their devices.
|
|
670
982
|
const grace = await awaitGrace(ctx, pending.asked_at ?? ctx.now());
|
|
@@ -676,7 +988,9 @@ async function escalate(ctx, envelope, sessionId, state, pending, notes) {
|
|
|
676
988
|
notes.push('no idle signal on this machine; asking now rather than holding the terminal');
|
|
677
989
|
}
|
|
678
990
|
const asked = await askAndWait(ctx, {
|
|
679
|
-
title:
|
|
991
|
+
title: ctx.config.project.value === null
|
|
992
|
+
? 'Question'
|
|
993
|
+
: `Question · ${ctx.config.project.value}`,
|
|
680
994
|
body: pending.question,
|
|
681
995
|
...(pending.choices !== undefined ? { choices: pending.choices } : {}),
|
|
682
996
|
event: 'agent_question',
|
|
@@ -685,16 +999,27 @@ async function escalate(ctx, envelope, sessionId, state, pending, notes) {
|
|
|
685
999
|
// useful to the next turn, which collects it with `notifai replies`.
|
|
686
1000
|
windowSeconds: QUESTION_WINDOW_SECONDS,
|
|
687
1001
|
onSubmitted: (live) => {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1002
|
+
const livePending = {
|
|
1003
|
+
...pending,
|
|
1004
|
+
request_id: live.requestId,
|
|
1005
|
+
collapse_key: live.collapseKey,
|
|
1006
|
+
device_ids: live.devices,
|
|
1007
|
+
};
|
|
1008
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1009
|
+
const stillCurrent = current.pending?.question === pending.question &&
|
|
1010
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
1011
|
+
current.pending?.request_id === undefined;
|
|
1012
|
+
if (stillCurrent)
|
|
1013
|
+
return { ...current, pending: livePending };
|
|
1014
|
+
// A newer `notifai ask` won while this submit was in flight. Keep it,
|
|
1015
|
+
// but now that request identifiers exist, preserve the old delivered
|
|
1016
|
+
// question as collectable/retirable instead of overwriting either one.
|
|
1017
|
+
const retirement = retiringQuestion(livePending, 'superseded');
|
|
1018
|
+
const retiring = [...(current.retiring ?? [])];
|
|
1019
|
+
if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
1020
|
+
retiring.push(retirement);
|
|
1021
|
+
}
|
|
1022
|
+
return { ...current, retiring };
|
|
698
1023
|
});
|
|
699
1024
|
},
|
|
700
1025
|
});
|
|
@@ -702,44 +1027,33 @@ async function escalate(ctx, envelope, sessionId, state, pending, notes) {
|
|
|
702
1027
|
notes.push(asked.error);
|
|
703
1028
|
return { notes };
|
|
704
1029
|
}
|
|
705
|
-
// What survives replacing the pending record. `retiring` has to: dropping it
|
|
706
|
-
// is exactly the class of bug this guards against — a delivered notification
|
|
707
|
-
// whose ids no longer exist anywhere.
|
|
708
|
-
const current = readSessionState(sessionId, ctx.env);
|
|
709
|
-
const carried = {
|
|
710
|
-
...(state.last_prompt_at !== undefined ? { last_prompt_at: state.last_prompt_at } : {}),
|
|
711
|
-
...(current.retiring !== undefined ? { retiring: current.retiring } : {}),
|
|
712
|
-
};
|
|
713
1030
|
if (!asked.reply) {
|
|
714
1031
|
// Keep the pending record so a returning user's UserPromptSubmit can retire
|
|
715
1032
|
// the notification that is still live on their devices. `request_id` being
|
|
716
1033
|
// set is also what stops the next Stop pushing the same question again.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1034
|
+
if (!asked.waited) {
|
|
1035
|
+
notes.push(`question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`);
|
|
1036
|
+
}
|
|
1037
|
+
else if (asked.userReturned) {
|
|
1038
|
+
notes.push(`you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
|
|
1039
|
+
'Retrieve it with: notifai replies --pending');
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
notes.push(asked.degraded
|
|
1043
|
+
? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
|
|
1044
|
+
: `no answer in time; retrieve it later with: notifai replies --pending`);
|
|
1045
|
+
}
|
|
729
1046
|
return { notes };
|
|
730
1047
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1048
|
+
await finishPendingAnswer(ctx, envelope, sessionId, {
|
|
1049
|
+
...pending,
|
|
1050
|
+
request_id: asked.requestId,
|
|
1051
|
+
collapse_key: asked.collapseKey,
|
|
1052
|
+
device_ids: asked.devices,
|
|
1053
|
+
}, asked.reply, true);
|
|
734
1054
|
notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`);
|
|
735
1055
|
return {
|
|
736
|
-
stdout:
|
|
737
|
-
decision: 'block',
|
|
738
|
-
// Harnesses render a Stop decision under an error-ish label, so this
|
|
739
|
-
// text must read as an answer on its own rather than as a failure.
|
|
740
|
-
reason: `NotifAI — the user answered from ${asked.reply.device_name}: ` +
|
|
741
|
-
`"${asked.reply.text}". Continue with that answer.`,
|
|
742
|
-
}),
|
|
1056
|
+
stdout: stopAnswerOutput(asked.reply),
|
|
743
1057
|
notes,
|
|
744
1058
|
};
|
|
745
1059
|
}
|
|
@@ -759,6 +1073,8 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
759
1073
|
const sessionId = envelope.session_id;
|
|
760
1074
|
if (!sessionId)
|
|
761
1075
|
return { notes };
|
|
1076
|
+
if (envelope.cwd !== undefined)
|
|
1077
|
+
clearMatchingProjectSession(envelope.cwd, env, sessionId);
|
|
762
1078
|
const state = readSessionState(sessionId, env);
|
|
763
1079
|
const orphans = [...(state.retiring ?? [])];
|
|
764
1080
|
const pending = state.pending;
|
|
@@ -801,17 +1117,24 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
801
1117
|
* agent's live one would be worse than the staleness this fixes.
|
|
802
1118
|
*/
|
|
803
1119
|
export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1120
|
+
updateSessionState(sessionId, env, (state) => {
|
|
1121
|
+
const retiring = [...(state.retiring ?? [])];
|
|
1122
|
+
if (state.pending !== undefined) {
|
|
1123
|
+
const retirement = retiringQuestion(state.pending, 'superseded');
|
|
1124
|
+
if (retirement !== null &&
|
|
1125
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
1126
|
+
retiring.push(retirement);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
...state,
|
|
1131
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1132
|
+
pending: {
|
|
1133
|
+
asked_at: now,
|
|
1134
|
+
...question,
|
|
1135
|
+
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
815
1138
|
});
|
|
816
1139
|
}
|
|
817
1140
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|