@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/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
239
  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
- 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,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 immediately: the first answer wins, and the
290
- * other devices must stop offering to change it. An unanswered one stays open,
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
- ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
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
- const result = await ctx.waitForFirstReply(receipt.request_id, timeoutSeconds);
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: result.replies[0] ?? null,
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 === true,
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
- 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],
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
- 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 : [] });
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 writeOrphanQueue(env, queue) {
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
- mkdirSync(path.dirname(file), { recursive: true });
515
- writeFileSync(file, `${JSON.stringify(queue, null, 2)}\n`, { mode: 0o600 });
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
- 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));
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
- 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 } : {}),
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 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());
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(`already asked (${pending.request_id}); waiting for that answer`);
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, state, pending, notes);
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, state, pending, notes) {
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: 'A question from your agent',
680
- body: pending.question,
681
- ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
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
- // 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
- },
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
- 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}`);
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
- // 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));
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: 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
- }),
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
- 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
- },
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. */