@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/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
- mkdirSync(path.dirname(file), { recursive: true });
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
- rmSync(sessionStatePath(sessionId, env), { force: true });
90
- // The session override lives in a sibling file; leaving it behind meant a
91
- // later session reusing the id silently inherited `ask_notifications = false`.
92
- rmSync(sessionConfigPath(sessionId, env), { force: true });
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
- * `wx` is the whole mechanism. Exclusive create is atomic on POSIX, so exactly
153
- * one process gets the claim and the other steps aside. A claim older than the
154
- * hook budget is a crashed process rather than a live one, and is broken — the
155
- * alternative is a stale lock file suppressing every question for that session
156
- * for ever, which is worse than the duplicate this prevents.
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
- export function claimQuestionPush(sessionId, env, now = Date.now()) {
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
- // Someone else broke it first and won. One of us proceeding is the point.
190
- return false;
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
- rmSync(claimPath(sessionId, env), { force: true });
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
- mkdirSync(path.dirname(file), { recursive: true });
211
- writeFileSync(file, `${JSON.stringify({ session_id: sessionId, updated_at: now })}\n`, {
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 === 'number' && now - parsed.updated_at > maxAgeMs)
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
- return parsed.session_id;
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
- const result = await ctx.waitForFirstReply(receipt.request_id, timeoutSeconds);
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 === true,
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
- const current = readSessionState(sessionId, env);
431
- const already = current.retiring ?? [];
432
- if (already.some((entry) => entry.request_id === retirement.request_id))
433
- return;
434
- writeSessionState(sessionId, env, {
435
- ...current,
436
- retiring: [...already, retirement],
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
- else
487
- remaining.push(entry);
488
- }
489
- // Re-read rather than reusing the snapshot: `notifai ask` may have parked
490
- // another question while these were in flight.
491
- const current = readSessionState(sessionId, env);
492
- const untouched = (current.retiring ?? []).filter((entry) => !retired.includes(entry.request_id));
493
- writeSessionState(sessionId, env, { ...current, retiring: untouched.length > 0 ? untouched : [] });
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 writeOrphanQueue(env, queue) {
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
- mkdirSync(path.dirname(file), { recursive: true });
515
- writeFileSync(file, `${JSON.stringify(queue, null, 2)}\n`, { mode: 0o600 });
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
- const queue = readOrphanQueue(env);
522
- const known = new Set(queue.map((entry) => entry.request_id));
523
- const added = entries
524
- .filter((entry) => !known.has(entry.request_id))
525
- .map((entry) => ({ ...entry, ...(session !== undefined ? { session } : {}), enqueued_at: now }));
526
- if (added.length === 0)
527
- return;
528
- writeOrphanQueue(env, [...queue, ...added].slice(-ORPHAN_QUEUE_CAP));
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
- if (pending !== undefined)
579
- parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere');
580
- const parked = readSessionState(sessionId, ctx.env);
581
- writeSessionState(sessionId, ctx.env, {
582
- last_prompt_at: ctx.now(),
583
- ...(parked.retiring !== undefined ? { retiring: parked.retiring } : {}),
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 its own session: an agent
586
- // shell command gets no hook payload and no harness exports the id.
587
- if (envelope.cwd !== undefined)
588
- writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now());
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(`already asked (${pending.request_id}); waiting for that answer`);
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, state, pending, notes);
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, state, pending, notes) {
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: 'A question from your agent',
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
- // Re-read: the drain at the top of this hook rewrote the queue, and the
689
- // snapshot in `state` predates it.
690
- writeSessionState(sessionId, ctx.env, {
691
- ...readSessionState(sessionId, ctx.env),
692
- pending: {
693
- ...pending,
694
- request_id: live.requestId,
695
- collapse_key: live.collapseKey,
696
- device_ids: live.devices,
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
- writeSessionState(sessionId, ctx.env, {
718
- ...carried,
719
- pending: {
720
- ...pending,
721
- request_id: asked.requestId,
722
- collapse_key: asked.collapseKey,
723
- device_ids: asked.devices,
724
- },
725
- });
726
- notes.push(asked.degraded
727
- ? `could not reach the server to find out whether you answered; check with: notifai replies ${asked.requestId}`
728
- : `no answer in time; retrieve it later with: notifai replies ${asked.requestId}`);
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
- // Answered and closed — nothing left to retire.
732
- writeSessionState(sessionId, ctx.env, carried);
733
- await retire(ctx, asked.collapseKey, 'Answered', asked.reply.text, 'answered', asked.requestId, asked.devices, sessionLabel(ctx, envelope));
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: JSON.stringify({
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
- const state = readSessionState(sessionId, env);
805
- if (state.pending !== undefined)
806
- parkForRetirement(sessionId, env, state.pending, 'superseded');
807
- // Re-read: parkForRetirement wrote the queue we must not clobber.
808
- writeSessionState(sessionId, env, {
809
- ...readSessionState(sessionId, env),
810
- pending: {
811
- asked_at: now,
812
- ...question,
813
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
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. */