@raidiant/notifai 0.1.7 → 0.2.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/README.md +10 -3
- 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 +46 -15
- package/dist/commands.js +760 -248
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +27 -12
- package/dist/hooks.js +519 -155
- 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 +80 -36
- 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 +19 -29
- package/dist/send.js +62 -57
- package/dist/send.js.map +1 -1
- package/package.json +5 -5
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +1036 -72
- package/src/commands.ts +923 -261
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +500 -9
- package/src/hooks.ts +624 -173
- package/src/install-hooks.test.ts +44 -16
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +95 -41
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +82 -60
- 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
239
|
try {
|
|
172
|
-
|
|
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
|
-
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,11 +438,16 @@ 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
|
*
|
|
289
|
-
* An answered question is closed
|
|
290
|
-
*
|
|
446
|
+
* An answered question is closed the moment this wait sees the answer: the
|
|
447
|
+
* first device to answer claims the question and the others are told. Until
|
|
448
|
+
* that close, a later reply is a correction — so what this returns as "the"
|
|
449
|
+
* answer is always the latest reply, with the full stream alongside for
|
|
450
|
+
* free-text answers given in parts. An unanswered question stays open,
|
|
291
451
|
* because nobody has acted on the silence yet and the next turn can still
|
|
292
452
|
* collect the answer with `notifai replies`.
|
|
293
453
|
*/
|
|
@@ -309,9 +469,9 @@ async function askAndWait(ctx, options) {
|
|
|
309
469
|
device: answerable,
|
|
310
470
|
reply: true,
|
|
311
471
|
replyWindow: Math.max(60, options.windowSeconds),
|
|
312
|
-
|
|
472
|
+
questions: options.questions,
|
|
473
|
+
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
|
313
474
|
collapseKey,
|
|
314
|
-
level: 'time_sensitive',
|
|
315
475
|
});
|
|
316
476
|
if (!build.ok)
|
|
317
477
|
return { error: build.error };
|
|
@@ -325,17 +485,59 @@ async function askAndWait(ctx, options) {
|
|
|
325
485
|
collapseKey,
|
|
326
486
|
devices: answerable,
|
|
327
487
|
});
|
|
328
|
-
|
|
488
|
+
if (ctx.harness === 'opencode') {
|
|
489
|
+
return {
|
|
490
|
+
requestId: receipt.request_id,
|
|
491
|
+
collapseKey,
|
|
492
|
+
reply: null,
|
|
493
|
+
replies: [],
|
|
494
|
+
devices: answerable,
|
|
495
|
+
degraded: false,
|
|
496
|
+
waited: false,
|
|
497
|
+
userReturned: false,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds);
|
|
329
501
|
if (result.replies.length > 0)
|
|
330
502
|
await closeQuietly(ctx, receipt.request_id);
|
|
331
503
|
return {
|
|
332
504
|
requestId: receipt.request_id,
|
|
333
505
|
collapseKey,
|
|
334
|
-
reply:
|
|
506
|
+
// The latest reply, not the first: an earlier conflicting answer was
|
|
507
|
+
// corrected by the one that followed it. The cross-device race is still
|
|
508
|
+
// first-answer-claims — that is what the close above enforces.
|
|
509
|
+
reply: result.replies.at(-1) ?? null,
|
|
510
|
+
replies: result.replies,
|
|
335
511
|
devices: answerable,
|
|
336
|
-
degraded: result.degraded
|
|
512
|
+
degraded: result.degraded,
|
|
513
|
+
waited: true,
|
|
514
|
+
userReturned: result.userReturned,
|
|
337
515
|
};
|
|
338
516
|
}
|
|
517
|
+
async function waitForReplyWhileAway(ctx, requestId, timeoutSeconds) {
|
|
518
|
+
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
519
|
+
let degraded = false;
|
|
520
|
+
let firstPoll = true;
|
|
521
|
+
for (;;) {
|
|
522
|
+
if (ctx.config.require_idle.value) {
|
|
523
|
+
const idle = ctx.idleSeconds();
|
|
524
|
+
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
525
|
+
return { replies: [], degraded, userReturned: true };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
529
|
+
if (!firstPoll && remainingMs === 0) {
|
|
530
|
+
return { replies: [], degraded, userReturned: false };
|
|
531
|
+
}
|
|
532
|
+
firstPoll = false;
|
|
533
|
+
const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
|
|
534
|
+
const result = await ctx.waitForFirstReply(requestId, pollSeconds);
|
|
535
|
+
degraded ||= result.degraded === true;
|
|
536
|
+
if (result.replies.length > 0) {
|
|
537
|
+
return { replies: result.replies, degraded, userReturned: false };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
339
541
|
/**
|
|
340
542
|
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
341
543
|
* macOS app register reply categories and submit answers directly.
|
|
@@ -427,13 +629,18 @@ export function parkForRetirement(sessionId, env, pending, state) {
|
|
|
427
629
|
const retirement = retiringQuestion(pending, state);
|
|
428
630
|
if (retirement === null)
|
|
429
631
|
return;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
...
|
|
436
|
-
|
|
632
|
+
updateSessionState(sessionId, env, (current) => {
|
|
633
|
+
const already = current.retiring ?? [];
|
|
634
|
+
const existing = already.findIndex((entry) => entry.request_id === retirement.request_id);
|
|
635
|
+
if (existing < 0)
|
|
636
|
+
return { ...current, retiring: [...already, retirement] };
|
|
637
|
+
const retiring = [...already];
|
|
638
|
+
// An observed answer is final truth and upgrades an earlier supersession
|
|
639
|
+
// parked while the submission callback was racing a newer question.
|
|
640
|
+
if (retirement.state === 'answered' && retiring[existing].state !== 'answered') {
|
|
641
|
+
retiring[existing] = retirement;
|
|
642
|
+
}
|
|
643
|
+
return { ...current, retiring };
|
|
437
644
|
});
|
|
438
645
|
}
|
|
439
646
|
/**
|
|
@@ -477,20 +684,19 @@ export async function drainRetirements(ctx, sessionId, env, session) {
|
|
|
477
684
|
if (queue.length === 0)
|
|
478
685
|
return [];
|
|
479
686
|
const retired = [];
|
|
480
|
-
const remaining = [];
|
|
481
687
|
for (const entry of queue) {
|
|
482
688
|
await closeQuietly(ctx, entry.request_id);
|
|
483
689
|
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)
|
|
690
|
+
if (sent) {
|
|
485
691
|
retired.push(entry.request_id);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
692
|
+
// Persist each success before touching the next entry. A later corrupt
|
|
693
|
+
// record or interrupted process must not resurrect work already done.
|
|
694
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
695
|
+
...current,
|
|
696
|
+
retiring: (current.retiring ?? []).filter((candidate) => candidate.request_id !== entry.request_id),
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
494
700
|
return retired;
|
|
495
701
|
}
|
|
496
702
|
function orphanQueuePath(env) {
|
|
@@ -509,23 +715,32 @@ function readOrphanQueue(env) {
|
|
|
509
715
|
return [];
|
|
510
716
|
}
|
|
511
717
|
}
|
|
512
|
-
function
|
|
718
|
+
function writeOrphanQueueUnlocked(file, queue) {
|
|
719
|
+
atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`);
|
|
720
|
+
}
|
|
721
|
+
function updateOrphanQueue(env, update) {
|
|
513
722
|
const file = orphanQueuePath(env);
|
|
514
|
-
|
|
515
|
-
|
|
723
|
+
return withFileLock(`${file}.lock`, () => {
|
|
724
|
+
const next = update(readOrphanQueue(env));
|
|
725
|
+
writeOrphanQueueUnlocked(file, next);
|
|
726
|
+
return next;
|
|
727
|
+
});
|
|
516
728
|
}
|
|
517
729
|
/** Move retirements into the global queue; deduped so a retry costs nothing. */
|
|
518
730
|
export function orphanRetirements(env, entries, session, now) {
|
|
519
731
|
if (entries.length === 0)
|
|
520
732
|
return;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
733
|
+
updateOrphanQueue(env, (queue) => {
|
|
734
|
+
const known = new Set(queue.map((entry) => entry.request_id));
|
|
735
|
+
const added = entries
|
|
736
|
+
.filter((entry) => !known.has(entry.request_id))
|
|
737
|
+
.map((entry) => ({
|
|
738
|
+
...entry,
|
|
739
|
+
...(session !== undefined ? { session } : {}),
|
|
740
|
+
enqueued_at: now,
|
|
741
|
+
}));
|
|
742
|
+
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP);
|
|
743
|
+
});
|
|
529
744
|
}
|
|
530
745
|
/**
|
|
531
746
|
* Retire everything a dead session left behind. Failures stay queued for the
|
|
@@ -542,20 +757,112 @@ export async function drainOrphanRetirements(ctx, env, now) {
|
|
|
542
757
|
// and cheap, so treat it as due rather than letting it linger for ever.
|
|
543
758
|
if (age > ORPHAN_TTL_MS) {
|
|
544
759
|
done.push(entry.request_id);
|
|
760
|
+
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
545
761
|
continue;
|
|
546
762
|
}
|
|
547
763
|
await closeQuietly(ctx, entry.request_id);
|
|
548
764
|
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)
|
|
765
|
+
if (sent) {
|
|
550
766
|
done.push(entry.request_id);
|
|
767
|
+
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
768
|
+
}
|
|
551
769
|
}
|
|
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
770
|
return done;
|
|
558
771
|
}
|
|
772
|
+
const LATE_STOP_POLL_SECONDS = 4;
|
|
773
|
+
const LATE_PROMPT_POLL_SECONDS = 3;
|
|
774
|
+
const MAX_CONTINUATION_COUNT = 3;
|
|
775
|
+
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
776
|
+
async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
777
|
+
if (pending.request_id === undefined) {
|
|
778
|
+
return { reply: null, replies: [], degraded: false, failed: false };
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
|
|
782
|
+
return {
|
|
783
|
+
reply: result.replies.at(-1) ?? null,
|
|
784
|
+
replies: result.replies,
|
|
785
|
+
degraded: result.degraded === true,
|
|
786
|
+
failed: false,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return { reply: null, replies: [], degraded: true, failed: true };
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** The question set this pending record pushes, however it was registered. */
|
|
794
|
+
function pendingQuestions(pending) {
|
|
795
|
+
return pending.questions ?? [{ id: 'q1', text: pending.question }];
|
|
796
|
+
}
|
|
797
|
+
/** Did any question in the set offer choices? Decides how replies combine. */
|
|
798
|
+
function pendingHasChoices(pending) {
|
|
799
|
+
return pendingQuestions(pending).some((question) => question.choices !== undefined);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* The answer as the agent should read it. For a question with choices the
|
|
803
|
+
* latest reply IS the answer — an earlier conflicting one was corrected by
|
|
804
|
+
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
805
|
+
* agent in the order it was written, so it can tell expansion from
|
|
806
|
+
* correction itself.
|
|
807
|
+
*/
|
|
808
|
+
function answerContext(replies, hadChoices) {
|
|
809
|
+
const latest = replies.at(-1);
|
|
810
|
+
if (latest === undefined)
|
|
811
|
+
return 'Notifai — no answer was recorded.';
|
|
812
|
+
if (replies.length === 1 || hadChoices) {
|
|
813
|
+
return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`;
|
|
814
|
+
}
|
|
815
|
+
const parts = replies.map((reply) => `"${reply.text}"`).join(', then ');
|
|
816
|
+
return (`Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
|
|
817
|
+
`in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
|
|
818
|
+
'Continue with that answer.');
|
|
819
|
+
}
|
|
820
|
+
function userPromptAnswerOutput(replies, hadChoices) {
|
|
821
|
+
return JSON.stringify({
|
|
822
|
+
hookSpecificOutput: {
|
|
823
|
+
hookEventName: 'UserPromptSubmit',
|
|
824
|
+
additionalContext: answerContext(replies, hadChoices),
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
function stopAnswerOutput(replies, hadChoices) {
|
|
829
|
+
return JSON.stringify({ decision: 'block', reason: answerContext(replies, hadChoices) });
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Close, truthfully retire, and forget one answered pending question without
|
|
833
|
+
* dropping any other retirement debt. Stop answers also open one bounded
|
|
834
|
+
* continuation generation; UserPromptSubmit answers ride the user's new turn.
|
|
835
|
+
*/
|
|
836
|
+
async function finishPendingAnswer(ctx, envelope, sessionId, pending, reply, continuation) {
|
|
837
|
+
const retirement = retiringQuestion(pending, 'answered');
|
|
838
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
839
|
+
const retiring = [...(current.retiring ?? [])];
|
|
840
|
+
if (retirement !== null) {
|
|
841
|
+
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id);
|
|
842
|
+
if (existing < 0)
|
|
843
|
+
retiring.push(retirement);
|
|
844
|
+
else
|
|
845
|
+
retiring[existing] = retirement;
|
|
846
|
+
}
|
|
847
|
+
const samePending = current.pending?.question === pending.question &&
|
|
848
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
849
|
+
current.pending?.request_id === pending.request_id;
|
|
850
|
+
const next = { ...current, retiring };
|
|
851
|
+
if (samePending)
|
|
852
|
+
delete next.pending;
|
|
853
|
+
if (continuation) {
|
|
854
|
+
next.continuation = {
|
|
855
|
+
answered_at: ctx.now(),
|
|
856
|
+
count: (envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
next.last_prompt_at = ctx.now();
|
|
861
|
+
}
|
|
862
|
+
return next;
|
|
863
|
+
});
|
|
864
|
+
await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
|
|
865
|
+
}
|
|
559
866
|
// ---------------------------------------------------------------------------
|
|
560
867
|
// UserPromptSubmit — the user is at the keyboard
|
|
561
868
|
// ---------------------------------------------------------------------------
|
|
@@ -572,20 +879,41 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
572
879
|
return { notes };
|
|
573
880
|
const state = readSessionState(sessionId, ctx.env);
|
|
574
881
|
const pending = state.pending;
|
|
882
|
+
if (pending?.request_id !== undefined) {
|
|
883
|
+
const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS);
|
|
884
|
+
if (late.reply !== null) {
|
|
885
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false);
|
|
886
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
887
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
888
|
+
}
|
|
889
|
+
await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
890
|
+
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
|
|
891
|
+
return { stdout: userPromptAnswerOutput(late.replies, pendingHasChoices(pending)), notes };
|
|
892
|
+
}
|
|
893
|
+
if (late.failed || late.degraded) {
|
|
894
|
+
notes.push('could not check the pending question for a late answer before the prompt');
|
|
895
|
+
}
|
|
896
|
+
}
|
|
575
897
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
576
898
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
577
899
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
900
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
901
|
+
const retiring = [...(current.retiring ?? [])];
|
|
902
|
+
if (current.pending !== undefined) {
|
|
903
|
+
const retirement = retiringQuestion(current.pending, 'answered_elsewhere');
|
|
904
|
+
if (retirement !== null &&
|
|
905
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
906
|
+
retiring.push(retirement);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) };
|
|
584
910
|
});
|
|
585
|
-
// The bridge that lets a plain `notifai ask` find
|
|
586
|
-
// shell command gets no hook payload and
|
|
587
|
-
|
|
588
|
-
|
|
911
|
+
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
912
|
+
// session: an agent shell command gets no hook payload, and not every
|
|
913
|
+
// harness exports an id in the same shape.
|
|
914
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
915
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
916
|
+
}
|
|
589
917
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
|
|
590
918
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
591
919
|
const swept = [...retired, ...orphaned];
|
|
@@ -625,22 +953,41 @@ export async function handleStop(ctx, envelope) {
|
|
|
625
953
|
if (swept.length > 0) {
|
|
626
954
|
notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
627
955
|
}
|
|
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
956
|
const state = readSessionState(sessionId, ctx.env);
|
|
635
957
|
const pending = state.pending;
|
|
636
958
|
if (!pending)
|
|
637
959
|
return { notes };
|
|
638
960
|
if (pending.request_id !== undefined) {
|
|
961
|
+
const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS);
|
|
962
|
+
if (late.reply !== null) {
|
|
963
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true);
|
|
964
|
+
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`);
|
|
965
|
+
return { stdout: stopAnswerOutput(late.replies, pendingHasChoices(pending)), notes };
|
|
966
|
+
}
|
|
639
967
|
// Already live on the user's devices from an earlier Stop; asking twice for
|
|
640
968
|
// one question is the nagging failure this feature exists to avoid.
|
|
641
|
-
notes.push(
|
|
969
|
+
notes.push(late.failed || late.degraded
|
|
970
|
+
? `already asked (${pending.request_id}); could not check whether its answer arrived`
|
|
971
|
+
: `already asked (${pending.request_id}); waiting for that answer`);
|
|
642
972
|
return { notes };
|
|
643
973
|
}
|
|
974
|
+
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
975
|
+
// Allow that new generation, but never re-run an old pending question and
|
|
976
|
+
// never let answer continuations become an unbounded agent loop.
|
|
977
|
+
if (envelope.stop_hook_active === true) {
|
|
978
|
+
const continuation = state.continuation;
|
|
979
|
+
const isNew = continuation !== undefined &&
|
|
980
|
+
pending.asked_at !== undefined &&
|
|
981
|
+
pending.asked_at > continuation.answered_at;
|
|
982
|
+
if (!isNew) {
|
|
983
|
+
notes.push('already continuing from an answer; not asking again this turn');
|
|
984
|
+
return { notes };
|
|
985
|
+
}
|
|
986
|
+
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
987
|
+
notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
|
|
988
|
+
return { notes };
|
|
989
|
+
}
|
|
990
|
+
}
|
|
644
991
|
if (!ctx.config.ask_notifications.value)
|
|
645
992
|
return { notes };
|
|
646
993
|
if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
|
|
@@ -657,14 +1004,14 @@ export async function handleStop(ctx, envelope) {
|
|
|
657
1004
|
return { notes };
|
|
658
1005
|
}
|
|
659
1006
|
try {
|
|
660
|
-
return await escalate(ctx, envelope, sessionId,
|
|
1007
|
+
return await escalate(ctx, envelope, sessionId, pending, notes);
|
|
661
1008
|
}
|
|
662
1009
|
finally {
|
|
663
1010
|
releaseQuestionPush(sessionId, ctx.env);
|
|
664
1011
|
}
|
|
665
1012
|
}
|
|
666
1013
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
667
|
-
async function escalate(ctx, envelope, sessionId,
|
|
1014
|
+
async function escalate(ctx, envelope, sessionId, pending, notes) {
|
|
668
1015
|
// Away right now, but the question still owes the user its terminal-first
|
|
669
1016
|
// window before anything reaches their devices.
|
|
670
1017
|
const grace = await awaitGrace(ctx, pending.asked_at ?? ctx.now());
|
|
@@ -675,26 +1022,45 @@ async function escalate(ctx, envelope, sessionId, state, pending, notes) {
|
|
|
675
1022
|
if (grace === 'no-signal') {
|
|
676
1023
|
notes.push('no idle signal on this machine; asking now rather than holding the terminal');
|
|
677
1024
|
}
|
|
1025
|
+
const questions = pendingQuestions(pending);
|
|
678
1026
|
const asked = await askAndWait(ctx, {
|
|
679
|
-
title:
|
|
680
|
-
|
|
681
|
-
|
|
1027
|
+
title: ctx.config.project.value === null
|
|
1028
|
+
? 'Question'
|
|
1029
|
+
: `Question · ${ctx.config.project.value}`,
|
|
1030
|
+
// A set is answered on the expanded card; the banner leads with the first
|
|
1031
|
+
// question and says how much more is waiting behind it.
|
|
1032
|
+
body: questions.length > 1
|
|
1033
|
+
? `${questions[0].text} (+${questions.length - 1} more)`
|
|
1034
|
+
: pending.question,
|
|
1035
|
+
questions,
|
|
1036
|
+
...(pending.detail !== undefined ? { detail: pending.detail } : {}),
|
|
682
1037
|
event: 'agent_question',
|
|
683
1038
|
session: sessionLabel(ctx, envelope),
|
|
684
1039
|
// Outlives the block, and stays open on purpose: the answer is still
|
|
685
1040
|
// useful to the next turn, which collects it with `notifai replies`.
|
|
686
1041
|
windowSeconds: QUESTION_WINDOW_SECONDS,
|
|
687
1042
|
onSubmitted: (live) => {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1043
|
+
const livePending = {
|
|
1044
|
+
...pending,
|
|
1045
|
+
request_id: live.requestId,
|
|
1046
|
+
collapse_key: live.collapseKey,
|
|
1047
|
+
device_ids: live.devices,
|
|
1048
|
+
};
|
|
1049
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1050
|
+
const stillCurrent = current.pending?.question === pending.question &&
|
|
1051
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
1052
|
+
current.pending?.request_id === undefined;
|
|
1053
|
+
if (stillCurrent)
|
|
1054
|
+
return { ...current, pending: livePending };
|
|
1055
|
+
// A newer `notifai ask` won while this submit was in flight. Keep it,
|
|
1056
|
+
// but now that request identifiers exist, preserve the old delivered
|
|
1057
|
+
// question as collectable/retirable instead of overwriting either one.
|
|
1058
|
+
const retirement = retiringQuestion(livePending, 'superseded');
|
|
1059
|
+
const retiring = [...(current.retiring ?? [])];
|
|
1060
|
+
if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
1061
|
+
retiring.push(retirement);
|
|
1062
|
+
}
|
|
1063
|
+
return { ...current, retiring };
|
|
698
1064
|
});
|
|
699
1065
|
},
|
|
700
1066
|
});
|
|
@@ -702,44 +1068,33 @@ async function escalate(ctx, envelope, sessionId, state, pending, notes) {
|
|
|
702
1068
|
notes.push(asked.error);
|
|
703
1069
|
return { notes };
|
|
704
1070
|
}
|
|
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
1071
|
if (!asked.reply) {
|
|
714
1072
|
// Keep the pending record so a returning user's UserPromptSubmit can retire
|
|
715
1073
|
// the notification that is still live on their devices. `request_id` being
|
|
716
1074
|
// 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
|
-
|
|
1075
|
+
if (!asked.waited) {
|
|
1076
|
+
notes.push(`question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`);
|
|
1077
|
+
}
|
|
1078
|
+
else if (asked.userReturned) {
|
|
1079
|
+
notes.push(`you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
|
|
1080
|
+
'Retrieve it with: notifai replies --pending');
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
notes.push(asked.degraded
|
|
1084
|
+
? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
|
|
1085
|
+
: `no answer in time; retrieve it later with: notifai replies --pending`);
|
|
1086
|
+
}
|
|
729
1087
|
return { notes };
|
|
730
1088
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1089
|
+
await finishPendingAnswer(ctx, envelope, sessionId, {
|
|
1090
|
+
...pending,
|
|
1091
|
+
request_id: asked.requestId,
|
|
1092
|
+
collapse_key: asked.collapseKey,
|
|
1093
|
+
device_ids: asked.devices,
|
|
1094
|
+
}, asked.reply, true);
|
|
734
1095
|
notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`);
|
|
735
1096
|
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
|
-
}),
|
|
1097
|
+
stdout: stopAnswerOutput(asked.replies, pendingHasChoices(pending)),
|
|
743
1098
|
notes,
|
|
744
1099
|
};
|
|
745
1100
|
}
|
|
@@ -759,6 +1114,8 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
759
1114
|
const sessionId = envelope.session_id;
|
|
760
1115
|
if (!sessionId)
|
|
761
1116
|
return { notes };
|
|
1117
|
+
if (envelope.cwd !== undefined)
|
|
1118
|
+
clearMatchingProjectSession(envelope.cwd, env, sessionId);
|
|
762
1119
|
const state = readSessionState(sessionId, env);
|
|
763
1120
|
const orphans = [...(state.retiring ?? [])];
|
|
764
1121
|
const pending = state.pending;
|
|
@@ -801,17 +1158,24 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
801
1158
|
* agent's live one would be worse than the staleness this fixes.
|
|
802
1159
|
*/
|
|
803
1160
|
export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1161
|
+
updateSessionState(sessionId, env, (state) => {
|
|
1162
|
+
const retiring = [...(state.retiring ?? [])];
|
|
1163
|
+
if (state.pending !== undefined) {
|
|
1164
|
+
const retirement = retiringQuestion(state.pending, 'superseded');
|
|
1165
|
+
if (retirement !== null &&
|
|
1166
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
1167
|
+
retiring.push(retirement);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return {
|
|
1171
|
+
...state,
|
|
1172
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1173
|
+
pending: {
|
|
1174
|
+
asked_at: now,
|
|
1175
|
+
...question,
|
|
1176
|
+
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
1177
|
+
},
|
|
1178
|
+
};
|
|
815
1179
|
});
|
|
816
1180
|
}
|
|
817
1181
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|