@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/src/hooks.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import { randomBytes } from 'node:crypto'
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs'
3
13
  import path from 'node:path'
4
- import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
14
+ import type { LifecycleEndState, QuestionT, ReplyView } from '@raidiant/notifai-protocol'
5
15
  import type { ApiClient } from './client.js'
6
16
  import {
7
17
  loadConfig,
@@ -12,15 +22,16 @@ import {
12
22
  type CliConfig,
13
23
  } from './config.js'
14
24
  import { buildDraft } from './send.js'
25
+ import { atomicWriteFileSync } from './atomic-file.js'
15
26
 
16
27
  /**
17
28
  * Harness hook handlers.
18
29
  *
19
- * Claude Code, Codex and OpenCode all expose the same three joints: a blocking
20
- * pre-approval hook that returns a decision, a turn-end hook, and a hook that
21
- * fires when the user submits a prompt. That is enough to answer a blocked
22
- * agent from the phone without the agent cooperating at all — no question
23
- * detection, no pending state in a context window that compaction will eat.
30
+ * The supported harnesses expose the same useful lifecycle joints: a turn-end
31
+ * event and an event that fires when the user submits a prompt. Claude Code,
32
+ * Codex, and Cursor can continue directly from a turn-end answer. OpenCode
33
+ * preserves the pending answer at turn end and injects it on the next prompt.
34
+ * No question detection or context-window state is needed in either path.
24
35
  *
25
36
  * The load-bearing constraint is that these hooks are synchronous: while one
26
37
  * blocks, the harness cannot show its own prompt either. So a hook may only
@@ -61,6 +72,11 @@ export interface SessionState {
61
72
  * attempt costs nothing and a missed one costs a stale notification for ever.
62
73
  */
63
74
  retiring?: RetiringQuestion[]
75
+ /** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
76
+ continuation?: {
77
+ answered_at: number
78
+ count: number
79
+ }
64
80
  }
65
81
 
66
82
  /** A delivered question awaiting its retirement push. */
@@ -104,6 +120,7 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
104
120
  const ORPHAN_QUEUE_CAP = 50
105
121
 
106
122
  export interface PendingQuestion {
123
+ /** One-line summary: the single question's text, or the set's first. */
107
124
  question: string
108
125
  /**
109
126
  * Epoch ms when `notifai ask` registered this. The grace window runs from
@@ -112,11 +129,12 @@ export interface PendingQuestion {
112
129
  */
113
130
  asked_at?: number
114
131
  /**
115
- * Canonical answer labels, already split and validated by `notifai ask`.
116
- * Stored as a list rather than a comma-joined string so a label containing a
117
- * comma survives the round trip.
132
+ * The full question set as `notifai ask` validated it: generated ids,
133
+ * texts, choices, multi flags. What actually rides the push.
118
134
  */
119
- choices?: string[]
135
+ questions?: QuestionT[]
136
+ /** Long-form markdown context, shown in the companion's detail view. */
137
+ detail?: string
120
138
  /** Set once the question has actually been pushed, so it can be retired. */
121
139
  request_id?: string
122
140
  collapse_key?: string
@@ -217,15 +235,83 @@ export function writeSessionState(
217
235
  state: SessionState,
218
236
  ): void {
219
237
  const file = sessionStatePath(sessionId, env)
220
- mkdirSync(path.dirname(file), { recursive: true })
221
- writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
238
+ withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state))
222
239
  }
223
240
 
224
241
  export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
225
- rmSync(sessionStatePath(sessionId, env), { force: true })
226
- // The session override lives in a sibling file; leaving it behind meant a
227
- // later session reusing the id silently inherited `ask_notifications = false`.
228
- rmSync(sessionConfigPath(sessionId, env), { force: true })
242
+ const file = sessionStatePath(sessionId, env)
243
+ withFileLock(`${file}.lock`, () => {
244
+ rmSync(file, { force: true })
245
+ // The session override lives in a sibling file; leaving it behind meant a
246
+ // later session reusing the id silently inherited `ask_notifications = false`.
247
+ rmSync(sessionConfigPath(sessionId, env), { force: true })
248
+ })
249
+ }
250
+
251
+ function writeSessionStateUnlocked(file: string, state: SessionState): void {
252
+ atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
253
+ }
254
+
255
+ function updateSessionState(
256
+ sessionId: string,
257
+ env: NodeJS.ProcessEnv,
258
+ update: (current: SessionState) => SessionState,
259
+ ): SessionState {
260
+ const file = sessionStatePath(sessionId, env)
261
+ return withFileLock(`${file}.lock`, () => {
262
+ const next = update(readSessionState(sessionId, env))
263
+ writeSessionStateUnlocked(file, next)
264
+ return next
265
+ })
266
+ }
267
+
268
+ /**
269
+ * Serialize a short read-modify-write without ever exposing partial JSON.
270
+ * A crashed holder is recoverable after 30 seconds; releases compare their
271
+ * random token under the same cooperating lock discipline before unlinking.
272
+ */
273
+ const FILE_LOCK_STALE_MS = 30_000
274
+ const FILE_LOCK_WAIT_MS = 1_000
275
+ const FILE_LOCK_POLL_MS = 5
276
+ const lockSleep = new Int32Array(new SharedArrayBuffer(4))
277
+
278
+ function withFileLock<T>(file: string, action: () => T): T {
279
+ mkdirSync(path.dirname(file), { recursive: true })
280
+ const token = randomBytes(12).toString('base64url')
281
+ const deadline = Date.now() + FILE_LOCK_WAIT_MS
282
+ for (;;) {
283
+ try {
284
+ const handle = openSync(file, 'wx', 0o600)
285
+ try {
286
+ writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
287
+ } finally {
288
+ closeSync(handle)
289
+ }
290
+ break
291
+ } catch (err) {
292
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
293
+ let stale = false
294
+ try {
295
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
296
+ stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
297
+ } catch {
298
+ stale = true
299
+ }
300
+ if (stale) rmSync(file, { force: true })
301
+ else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
302
+ else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
303
+ }
304
+ }
305
+ try {
306
+ return action()
307
+ } finally {
308
+ try {
309
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
310
+ if (held.token === token) rmSync(file, { force: true })
311
+ } catch {
312
+ // A replaced or externally removed lock is no longer ours to release.
313
+ }
314
+ }
229
315
  }
230
316
 
231
317
  /**
@@ -288,52 +374,101 @@ export function pruneAbandonedSessions(
288
374
  * pending question, see no `request_id`, and both escalate: one question, two
289
375
  * notifications.
290
376
  *
291
- * `wx` is the whole mechanism. Exclusive create is atomic on POSIX, so exactly
292
- * one process gets the claim and the other steps aside. A claim older than the
293
- * hook budget is a crashed process rather than a live one, and is broken — the
294
- * alternative is a stale lock file suppressing every question for that session
295
- * for ever, which is worse than the duplicate this prevents.
377
+ * Exclusive create is atomic on POSIX, so exactly one process gets the claim
378
+ * and the other steps aside. A short guard serializes stale replacement with
379
+ * contenders and releases; random ownership tokens keep an old holder from
380
+ * unlinking its replacement. A claim older than the hook budget is a crashed
381
+ * process rather than a live one and is broken, avoiding permanent suppression.
296
382
  */
297
383
  const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
384
+ const heldClaims = new Map<string, string>()
298
385
 
299
386
  export function claimQuestionPush(
300
387
  sessionId: string,
301
388
  env: NodeJS.ProcessEnv,
302
389
  now: number = Date.now(),
390
+ beforeStaleReplace?: () => void,
303
391
  ): boolean {
304
392
  const file = claimPath(sessionId, env)
393
+ const guard = `${file}.guard`
394
+ const token = randomBytes(12).toString('base64url')
305
395
  mkdirSync(path.dirname(file), { recursive: true })
396
+ if (!acquireClaimGuard(guard)) return false
306
397
  try {
307
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
308
- mode: 0o600,
309
- flag: 'wx',
310
- })
311
- return true
312
- } catch {
313
- // Held. Break it only if whoever holds it cannot still be running.
314
398
  try {
315
- const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
316
- const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
317
- if (age >= 0 && age < CLAIM_TTL_MS) return false
318
- } catch {
319
- // Unreadable or corrupt: treat as abandoned.
320
- }
321
- rmSync(file, { force: true })
322
- try {
323
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
399
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
324
400
  mode: 0o600,
325
401
  flag: 'wx',
326
402
  })
403
+ heldClaims.set(file, token)
327
404
  return true
328
405
  } catch {
329
- // Someone else broke it first and won. One of us proceeding is the point.
330
- return false
406
+ // Held. Break it only if whoever holds it cannot still be running.
407
+ try {
408
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
409
+ const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
410
+ if (age >= 0 && age < CLAIM_TTL_MS) return false
411
+ } catch {
412
+ // Unreadable or corrupt: treat as abandoned.
413
+ }
414
+ beforeStaleReplace?.()
415
+ rmSync(file, { force: true })
416
+ try {
417
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
418
+ mode: 0o600,
419
+ flag: 'wx',
420
+ })
421
+ heldClaims.set(file, token)
422
+ return true
423
+ } catch {
424
+ return false
425
+ }
331
426
  }
427
+ } finally {
428
+ rmSync(guard, { force: true })
332
429
  }
333
430
  }
334
431
 
335
432
  export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
336
- rmSync(claimPath(sessionId, env), { force: true })
433
+ const file = claimPath(sessionId, env)
434
+ const token = heldClaims.get(file)
435
+ if (token === undefined) return
436
+ const guard = `${file}.guard`
437
+ if (!acquireClaimGuard(guard)) {
438
+ heldClaims.delete(file)
439
+ return
440
+ }
441
+ try {
442
+ try {
443
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
444
+ if (held.token === token) rmSync(file, { force: true })
445
+ } catch {
446
+ // Missing or replaced claims are not ours to release.
447
+ }
448
+ } finally {
449
+ heldClaims.delete(file)
450
+ rmSync(guard, { force: true })
451
+ }
452
+ }
453
+
454
+ /** The guard serializes stale replacement and release; recover a crashed guard. */
455
+ function acquireClaimGuard(file: string): boolean {
456
+ for (let attempt = 0; attempt < 2; attempt += 1) {
457
+ try {
458
+ const handle = openSync(file, 'wx', 0o600)
459
+ closeSync(handle)
460
+ return true
461
+ } catch (err) {
462
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
463
+ try {
464
+ if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS) return false
465
+ } catch {
466
+ // A vanished/corrupt guard is safe to retry once.
467
+ }
468
+ rmSync(file, { force: true })
469
+ }
470
+ }
471
+ return false
337
472
  }
338
473
 
339
474
  function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
@@ -354,14 +489,22 @@ export function writeProjectSession(
354
489
  env: NodeJS.ProcessEnv,
355
490
  sessionId: string,
356
491
  now: number,
492
+ harness: HookHarness,
357
493
  ): void {
358
494
  const file = projectSessionPointerPath(cwd, env)
359
- mkdirSync(path.dirname(file), { recursive: true })
360
- writeFileSync(file, `${JSON.stringify({ session_id: sessionId, updated_at: now })}\n`, {
361
- mode: 0o600,
495
+ withFileLock(`${file}.lock`, () => {
496
+ atomicWriteFileSync(
497
+ file,
498
+ `${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`,
499
+ )
362
500
  })
363
501
  }
364
502
 
503
+ export interface ProjectSessionPointer {
504
+ sessionId: string
505
+ harness: HookHarness
506
+ }
507
+
365
508
  /**
366
509
  * Resolves the session working in `cwd`. Stale pointers are ignored rather than
367
510
  * trusted: an id left by a session that ended days ago would send the question
@@ -373,21 +516,57 @@ export function readProjectSession(
373
516
  now: number,
374
517
  maxAgeMs = 24 * 3600 * 1000,
375
518
  ): string | null {
519
+ return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
520
+ }
521
+
522
+ export function readProjectSessionPointer(
523
+ cwd: string,
524
+ env: NodeJS.ProcessEnv,
525
+ now: number,
526
+ maxAgeMs = 24 * 3600 * 1000,
527
+ ): ProjectSessionPointer | null {
376
528
  const file = projectSessionPointerPath(cwd, env)
377
529
  if (!existsSync(file)) return null
378
530
  try {
379
531
  const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
380
532
  session_id?: unknown
381
533
  updated_at?: unknown
534
+ harness?: unknown
382
535
  }
383
536
  if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
384
- if (typeof parsed.updated_at === 'number' && now - parsed.updated_at > maxAgeMs) return null
385
- return parsed.session_id
537
+ if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
538
+ if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
539
+ // A pointer is routing evidence only while its session state still exists
540
+ // and parses. SessionEnd and explicit cleanup therefore invalidate it even
541
+ // when a crash prevented the pointer file itself from being removed.
542
+ const sessionFile = sessionStatePath(parsed.session_id, env)
543
+ if (!existsSync(sessionFile)) return null
544
+ const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
545
+ if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
546
+ return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
386
547
  } catch {
387
548
  return null
388
549
  }
389
550
  }
390
551
 
552
+ const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
553
+
554
+ function clearMatchingProjectSession(
555
+ cwd: string,
556
+ env: NodeJS.ProcessEnv,
557
+ sessionId: string,
558
+ ): void {
559
+ const file = projectSessionPointerPath(cwd, env)
560
+ withFileLock(`${file}.lock`, () => {
561
+ try {
562
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: unknown }
563
+ if (parsed.session_id === sessionId) rmSync(file, { force: true })
564
+ } catch {
565
+ // Missing/corrupt is already not a live pointer.
566
+ }
567
+ })
568
+ }
569
+
391
570
  /**
392
571
  * Absent means the user is not at this machine's keyboard.
393
572
  *
@@ -455,23 +634,40 @@ export interface HookContext {
455
634
  requestId: string,
456
635
  timeoutSeconds: number,
457
636
  ) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
637
+ /** OpenCode cannot consume Stop stdout, so its adapter must never wait there. */
638
+ harness?: HookHarness
458
639
  }
459
640
 
641
+ export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
642
+
460
643
  interface AskResult {
461
644
  requestId: string
462
645
  collapseKey: string
646
+ /** The answer to act on: the latest reply, because a later one corrects. */
463
647
  reply: ReplyView | null
648
+ /** Every reply in arrival order, for free-text answers given in parts. */
649
+ replies: ReplyView[]
464
650
  /** The devices the question went to; retirement must not reach any other. */
465
651
  devices: string[]
466
652
  /** The wait ended amid network failures, so "no answer" is unproven. */
467
653
  degraded: boolean
654
+ /** False when the harness cannot consume a Stop answer and polling was skipped. */
655
+ waited: boolean
656
+ /** The user returned to this machine after the question was pushed. */
657
+ userReturned: boolean
468
658
  }
469
659
 
660
+ /** Re-check local presence at this cadence while a pushed question is waiting. */
661
+ const REPLY_PRESENCE_POLL_SECONDS = 5
662
+
470
663
  /**
471
664
  * Push a question and block for the answer.
472
665
  *
473
- * An answered question is closed immediately: the first answer wins, and the
474
- * other devices must stop offering to change it. An unanswered one stays open,
666
+ * An answered question is closed the moment this wait sees the answer: the
667
+ * first device to answer claims the question and the others are told. Until
668
+ * that close, a later reply is a correction — so what this returns as "the"
669
+ * answer is always the latest reply, with the full stream alongside for
670
+ * free-text answers given in parts. An unanswered question stays open,
475
671
  * because nobody has acted on the silence yet and the next turn can still
476
672
  * collect the answer with `notifai replies`.
477
673
  */
@@ -480,7 +676,8 @@ async function askAndWait(
480
676
  options: {
481
677
  title: string
482
678
  body: string
483
- choices?: string[]
679
+ questions: QuestionT[]
680
+ detail?: string | undefined
484
681
  event: string
485
682
  /** Which agent is asking; two of them must not look alike. */
486
683
  session?: string | undefined
@@ -507,9 +704,9 @@ async function askAndWait(
507
704
  device: answerable,
508
705
  reply: true,
509
706
  replyWindow: Math.max(60, options.windowSeconds),
510
- ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
707
+ questions: options.questions,
708
+ ...(options.detail !== undefined ? { detail: options.detail } : {}),
511
709
  collapseKey,
512
- level: 'time_sensitive',
513
710
  })
514
711
  if (!build.ok) return { error: build.error }
515
712
 
@@ -526,14 +723,67 @@ async function askAndWait(
526
723
  collapseKey,
527
724
  devices: answerable,
528
725
  })
529
- const result = await ctx.waitForFirstReply(receipt.request_id, timeoutSeconds)
726
+ if (ctx.harness === 'opencode') {
727
+ return {
728
+ requestId: receipt.request_id,
729
+ collapseKey,
730
+ reply: null,
731
+ replies: [],
732
+ devices: answerable,
733
+ degraded: false,
734
+ waited: false,
735
+ userReturned: false,
736
+ }
737
+ }
738
+
739
+ const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds)
530
740
  if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
531
741
  return {
532
742
  requestId: receipt.request_id,
533
743
  collapseKey,
534
- reply: result.replies[0] ?? null,
744
+ // The latest reply, not the first: an earlier conflicting answer was
745
+ // corrected by the one that followed it. The cross-device race is still
746
+ // first-answer-claims — that is what the close above enforces.
747
+ reply: result.replies.at(-1) ?? null,
748
+ replies: result.replies,
535
749
  devices: answerable,
536
- degraded: result.degraded === true,
750
+ degraded: result.degraded,
751
+ waited: true,
752
+ userReturned: result.userReturned,
753
+ }
754
+ }
755
+
756
+ async function waitForReplyWhileAway(
757
+ ctx: HookContext,
758
+ requestId: string,
759
+ timeoutSeconds: number,
760
+ ): Promise<{ replies: ReplyView[]; degraded: boolean; userReturned: boolean }> {
761
+ const deadline = ctx.now() + timeoutSeconds * 1000
762
+ let degraded = false
763
+ let firstPoll = true
764
+
765
+ for (;;) {
766
+ if (ctx.config.require_idle.value) {
767
+ const idle = ctx.idleSeconds()
768
+ if (idle !== null && idle < ctx.config.away_after_seconds.value) {
769
+ return { replies: [], degraded, userReturned: true }
770
+ }
771
+ }
772
+
773
+ const remainingMs = Math.max(0, deadline - ctx.now())
774
+ if (!firstPoll && remainingMs === 0) {
775
+ return { replies: [], degraded, userReturned: false }
776
+ }
777
+ firstPoll = false
778
+ const pollSeconds = Math.min(
779
+ REPLY_PRESENCE_POLL_SECONDS,
780
+ Math.max(0, Math.ceil(remainingMs / 1000)),
781
+ )
782
+ const result = await ctx.waitForFirstReply(requestId, pollSeconds)
783
+ degraded ||= result.degraded === true
784
+ if (result.replies.length > 0) {
785
+ return { replies: result.replies, degraded, userReturned: false }
786
+ }
537
787
  }
538
788
  }
539
789
 
@@ -655,12 +905,17 @@ export function parkForRetirement(
655
905
  ): void {
656
906
  const retirement = retiringQuestion(pending, state)
657
907
  if (retirement === null) return
658
- const current = readSessionState(sessionId, env)
659
- const already = current.retiring ?? []
660
- if (already.some((entry) => entry.request_id === retirement.request_id)) return
661
- writeSessionState(sessionId, env, {
662
- ...current,
663
- retiring: [...already, retirement],
908
+ updateSessionState(sessionId, env, (current) => {
909
+ const already = current.retiring ?? []
910
+ const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
911
+ if (existing < 0) return { ...current, retiring: [...already, retirement] }
912
+ const retiring = [...already]
913
+ // An observed answer is final truth and upgrades an earlier supersession
914
+ // parked while the submission callback was racing a newer question.
915
+ if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
916
+ retiring[existing] = retirement
917
+ }
918
+ return { ...current, retiring }
664
919
  })
665
920
  }
666
921
 
@@ -718,7 +973,6 @@ export async function drainRetirements(
718
973
  if (queue.length === 0) return []
719
974
 
720
975
  const retired: string[] = []
721
- const remaining: RetiringQuestion[] = []
722
976
  for (const entry of queue) {
723
977
  await closeQuietly(ctx, entry.request_id)
724
978
  const sent = await retire(
@@ -731,17 +985,18 @@ export async function drainRetirements(
731
985
  retirementDeviceIds(entry),
732
986
  session,
733
987
  )
734
- if (sent) retired.push(entry.request_id)
735
- else remaining.push(entry)
988
+ if (sent) {
989
+ retired.push(entry.request_id)
990
+ // Persist each success before touching the next entry. A later corrupt
991
+ // record or interrupted process must not resurrect work already done.
992
+ updateSessionState(sessionId, env, (current) => ({
993
+ ...current,
994
+ retiring: (current.retiring ?? []).filter(
995
+ (candidate) => candidate.request_id !== entry.request_id,
996
+ ),
997
+ }))
998
+ }
736
999
  }
737
-
738
- // Re-read rather than reusing the snapshot: `notifai ask` may have parked
739
- // another question while these were in flight.
740
- const current = readSessionState(sessionId, env)
741
- const untouched = (current.retiring ?? []).filter(
742
- (entry) => !retired.includes(entry.request_id),
743
- )
744
- writeSessionState(sessionId, env, { ...current, retiring: untouched.length > 0 ? untouched : [] })
745
1000
  return retired
746
1001
  }
747
1002
 
@@ -761,10 +1016,20 @@ function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
761
1016
  }
762
1017
  }
763
1018
 
764
- function writeOrphanQueue(env: NodeJS.ProcessEnv, queue: OrphanRetirement[]): void {
1019
+ function writeOrphanQueueUnlocked(file: string, queue: OrphanRetirement[]): void {
1020
+ atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`)
1021
+ }
1022
+
1023
+ function updateOrphanQueue(
1024
+ env: NodeJS.ProcessEnv,
1025
+ update: (current: OrphanRetirement[]) => OrphanRetirement[],
1026
+ ): OrphanRetirement[] {
765
1027
  const file = orphanQueuePath(env)
766
- mkdirSync(path.dirname(file), { recursive: true })
767
- writeFileSync(file, `${JSON.stringify(queue, null, 2)}\n`, { mode: 0o600 })
1028
+ return withFileLock(`${file}.lock`, () => {
1029
+ const next = update(readOrphanQueue(env))
1030
+ writeOrphanQueueUnlocked(file, next)
1031
+ return next
1032
+ })
768
1033
  }
769
1034
 
770
1035
  /** Move retirements into the global queue; deduped so a retry costs nothing. */
@@ -775,13 +1040,17 @@ export function orphanRetirements(
775
1040
  now: number,
776
1041
  ): void {
777
1042
  if (entries.length === 0) return
778
- const queue = readOrphanQueue(env)
779
- const known = new Set(queue.map((entry) => entry.request_id))
780
- const added = entries
781
- .filter((entry) => !known.has(entry.request_id))
782
- .map((entry) => ({ ...entry, ...(session !== undefined ? { session } : {}), enqueued_at: now }))
783
- if (added.length === 0) return
784
- writeOrphanQueue(env, [...queue, ...added].slice(-ORPHAN_QUEUE_CAP))
1043
+ updateOrphanQueue(env, (queue) => {
1044
+ const known = new Set(queue.map((entry) => entry.request_id))
1045
+ const added = entries
1046
+ .filter((entry) => !known.has(entry.request_id))
1047
+ .map((entry) => ({
1048
+ ...entry,
1049
+ ...(session !== undefined ? { session } : {}),
1050
+ enqueued_at: now,
1051
+ }))
1052
+ return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
1053
+ })
785
1054
  }
786
1055
 
787
1056
  /**
@@ -803,6 +1072,9 @@ export async function drainOrphanRetirements(
803
1072
  // and cheap, so treat it as due rather than letting it linger for ever.
804
1073
  if (age > ORPHAN_TTL_MS) {
805
1074
  done.push(entry.request_id)
1075
+ updateOrphanQueue(env, (current) =>
1076
+ current.filter((candidate) => candidate.request_id !== entry.request_id),
1077
+ )
806
1078
  continue
807
1079
  }
808
1080
  await closeQuietly(ctx, entry.request_id)
@@ -816,17 +1088,136 @@ export async function drainOrphanRetirements(
816
1088
  retirementDeviceIds(entry),
817
1089
  entry.session,
818
1090
  )
819
- if (sent) done.push(entry.request_id)
1091
+ if (sent) {
1092
+ done.push(entry.request_id)
1093
+ updateOrphanQueue(env, (current) =>
1094
+ current.filter((candidate) => candidate.request_id !== entry.request_id),
1095
+ )
1096
+ }
820
1097
  }
821
-
822
- // Re-read: another session's SessionEnd may have queued more while these were
823
- // in flight, and clobbering its write would recreate the very loss this
824
- // queue exists to prevent.
825
- const current = readOrphanQueue(env).filter((entry) => !done.includes(entry.request_id))
826
- writeOrphanQueue(env, current)
827
1098
  return done
828
1099
  }
829
1100
 
1101
+ const LATE_STOP_POLL_SECONDS = 4
1102
+ const LATE_PROMPT_POLL_SECONDS = 3
1103
+ const MAX_CONTINUATION_COUNT = 3
1104
+
1105
+ interface PendingPoll {
1106
+ /** The answer to act on: the latest reply, because a later one corrects. */
1107
+ reply: ReplyView | null
1108
+ /** Every reply in arrival order, for free-text answers given in parts. */
1109
+ replies: ReplyView[]
1110
+ degraded: boolean
1111
+ failed: boolean
1112
+ }
1113
+
1114
+ /** One bounded recovery poll for a question that outlived its original wait. */
1115
+ async function pollPendingReply(
1116
+ ctx: HookContext,
1117
+ pending: PendingQuestion,
1118
+ timeoutSeconds: number,
1119
+ ): Promise<PendingPoll> {
1120
+ if (pending.request_id === undefined) {
1121
+ return { reply: null, replies: [], degraded: false, failed: false }
1122
+ }
1123
+ try {
1124
+ const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
1125
+ return {
1126
+ reply: result.replies.at(-1) ?? null,
1127
+ replies: result.replies,
1128
+ degraded: result.degraded === true,
1129
+ failed: false,
1130
+ }
1131
+ } catch {
1132
+ return { reply: null, replies: [], degraded: true, failed: true }
1133
+ }
1134
+ }
1135
+
1136
+ /** The question set this pending record pushes, however it was registered. */
1137
+ function pendingQuestions(pending: PendingQuestion): QuestionT[] {
1138
+ return pending.questions ?? [{ id: 'q1', text: pending.question }]
1139
+ }
1140
+
1141
+ /** Did any question in the set offer choices? Decides how replies combine. */
1142
+ function pendingHasChoices(pending: PendingQuestion): boolean {
1143
+ return pendingQuestions(pending).some((question) => question.choices !== undefined)
1144
+ }
1145
+
1146
+ /**
1147
+ * The answer as the agent should read it. For a question with choices the
1148
+ * latest reply IS the answer — an earlier conflicting one was corrected by
1149
+ * it. Free-text answers can arrive in parts, and every part reaches the
1150
+ * agent in the order it was written, so it can tell expansion from
1151
+ * correction itself.
1152
+ */
1153
+ function answerContext(replies: ReplyView[], hadChoices: boolean): string {
1154
+ const latest = replies.at(-1)
1155
+ if (latest === undefined) return 'Notifai — no answer was recorded.'
1156
+ if (replies.length === 1 || hadChoices) {
1157
+ return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
1158
+ }
1159
+ const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
1160
+ return (
1161
+ `Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
1162
+ `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
1163
+ 'Continue with that answer.'
1164
+ )
1165
+ }
1166
+
1167
+ function userPromptAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
1168
+ return JSON.stringify({
1169
+ hookSpecificOutput: {
1170
+ hookEventName: 'UserPromptSubmit',
1171
+ additionalContext: answerContext(replies, hadChoices),
1172
+ },
1173
+ })
1174
+ }
1175
+
1176
+ function stopAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
1177
+ return JSON.stringify({ decision: 'block', reason: answerContext(replies, hadChoices) })
1178
+ }
1179
+
1180
+ /**
1181
+ * Close, truthfully retire, and forget one answered pending question without
1182
+ * dropping any other retirement debt. Stop answers also open one bounded
1183
+ * continuation generation; UserPromptSubmit answers ride the user's new turn.
1184
+ */
1185
+ async function finishPendingAnswer(
1186
+ ctx: HookContext,
1187
+ envelope: HookEnvelope,
1188
+ sessionId: string,
1189
+ pending: PendingQuestion,
1190
+ reply: ReplyView,
1191
+ continuation: boolean,
1192
+ ): Promise<void> {
1193
+ const retirement = retiringQuestion(pending, 'answered')
1194
+ updateSessionState(sessionId, ctx.env, (current) => {
1195
+ const retiring = [...(current.retiring ?? [])]
1196
+ if (retirement !== null) {
1197
+ const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
1198
+ if (existing < 0) retiring.push(retirement)
1199
+ else retiring[existing] = retirement
1200
+ }
1201
+ const samePending =
1202
+ current.pending?.question === pending.question &&
1203
+ current.pending?.asked_at === pending.asked_at &&
1204
+ current.pending?.request_id === pending.request_id
1205
+ const next = { ...current, retiring }
1206
+ if (samePending) delete next.pending
1207
+ if (continuation) {
1208
+ next.continuation = {
1209
+ answered_at: ctx.now(),
1210
+ count:
1211
+ (envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
1212
+ }
1213
+ } else {
1214
+ next.last_prompt_at = ctx.now()
1215
+ }
1216
+ return next
1217
+ })
1218
+ await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1219
+ }
1220
+
830
1221
  // ---------------------------------------------------------------------------
831
1222
  // UserPromptSubmit — the user is at the keyboard
832
1223
  // ---------------------------------------------------------------------------
@@ -847,18 +1238,43 @@ export async function handleUserPromptSubmit(
847
1238
 
848
1239
  const state = readSessionState(sessionId, ctx.env)
849
1240
  const pending = state.pending
1241
+ if (pending?.request_id !== undefined) {
1242
+ const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS)
1243
+ if (late.reply !== null) {
1244
+ await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false)
1245
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1246
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1247
+ }
1248
+ await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1249
+ notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1250
+ return { stdout: userPromptAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
1251
+ }
1252
+ if (late.failed || late.degraded) {
1253
+ notes.push('could not check the pending question for a late answer before the prompt')
1254
+ }
1255
+ }
850
1256
  // Park before dropping `pending`. If the process dies between these writes,
851
1257
  // the next hook sees both copies and dedupes them; the old order could die in
852
1258
  // the gap after erasing the only request/collapse/device identifiers.
853
- if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
854
- const parked = readSessionState(sessionId, ctx.env)
855
- writeSessionState(sessionId, ctx.env, {
856
- last_prompt_at: ctx.now(),
857
- ...(parked.retiring !== undefined ? { retiring: parked.retiring } : {}),
1259
+ updateSessionState(sessionId, ctx.env, (current) => {
1260
+ const retiring = [...(current.retiring ?? [])]
1261
+ if (current.pending !== undefined) {
1262
+ const retirement = retiringQuestion(current.pending, 'answered_elsewhere')
1263
+ if (
1264
+ retirement !== null &&
1265
+ !retiring.some((entry) => entry.request_id === retirement.request_id)
1266
+ ) {
1267
+ retiring.push(retirement)
1268
+ }
1269
+ }
1270
+ return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) }
858
1271
  })
859
- // The bridge that lets a plain `notifai ask` find its own session: an agent
860
- // shell command gets no hook payload and no harness exports the id.
861
- if (envelope.cwd !== undefined) writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now())
1272
+ // The bridge that lets a plain `notifai ask` find the hook's canonical
1273
+ // session: an agent shell command gets no hook payload, and not every
1274
+ // harness exports an id in the same shape.
1275
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1276
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1277
+ }
862
1278
 
863
1279
  const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
864
1280
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
@@ -903,22 +1319,46 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
903
1319
  notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
904
1320
  }
905
1321
 
906
- // The harness sets this when it is already resuming us from a previous Stop
907
- // decision. Pushing again here is how a question turns into nagging.
908
- if (envelope.stop_hook_active === true) {
909
- notes.push('already continuing from an answer; not asking again this turn')
910
- return { notes }
911
- }
912
-
913
1322
  const state = readSessionState(sessionId, ctx.env)
914
1323
  const pending = state.pending
915
1324
  if (!pending) return { notes }
916
1325
  if (pending.request_id !== undefined) {
1326
+ const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS)
1327
+ if (late.reply !== null) {
1328
+ await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
1329
+ notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1330
+ return { stdout: stopAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
1331
+ }
917
1332
  // Already live on the user's devices from an earlier Stop; asking twice for
918
1333
  // one question is the nagging failure this feature exists to avoid.
919
- notes.push(`already asked (${pending.request_id}); waiting for that answer`)
1334
+ notes.push(
1335
+ late.failed || late.degraded
1336
+ ? `already asked (${pending.request_id}); could not check whether its answer arrived`
1337
+ : `already asked (${pending.request_id}); waiting for that answer`,
1338
+ )
920
1339
  return { notes }
921
1340
  }
1341
+
1342
+ // A Stop answer may immediately produce a legitimate follow-up question.
1343
+ // Allow that new generation, but never re-run an old pending question and
1344
+ // never let answer continuations become an unbounded agent loop.
1345
+ if (envelope.stop_hook_active === true) {
1346
+ const continuation = state.continuation
1347
+ const isNew =
1348
+ continuation !== undefined &&
1349
+ pending.asked_at !== undefined &&
1350
+ pending.asked_at > continuation.answered_at
1351
+ if (!isNew) {
1352
+ notes.push('already continuing from an answer; not asking again this turn')
1353
+ return { notes }
1354
+ }
1355
+ if (continuation.count >= MAX_CONTINUATION_COUNT) {
1356
+ notes.push(
1357
+ `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
1358
+ )
1359
+ return { notes }
1360
+ }
1361
+ }
922
1362
  if (!ctx.config.ask_notifications.value) return { notes }
923
1363
  if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
924
1364
  notes.push('you are at the keyboard; leaving the question in the terminal')
@@ -935,7 +1375,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
935
1375
  return { notes }
936
1376
  }
937
1377
  try {
938
- return await escalate(ctx, envelope, sessionId, state, pending, notes)
1378
+ return await escalate(ctx, envelope, sessionId, pending, notes)
939
1379
  } finally {
940
1380
  releaseQuestionPush(sessionId, ctx.env)
941
1381
  }
@@ -946,7 +1386,6 @@ async function escalate(
946
1386
  ctx: HookContext,
947
1387
  envelope: HookEnvelope,
948
1388
  sessionId: string,
949
- state: SessionState,
950
1389
  pending: PendingQuestion,
951
1390
  notes: string[],
952
1391
  ): Promise<HookOutcome> {
@@ -961,26 +1400,48 @@ async function escalate(
961
1400
  notes.push('no idle signal on this machine; asking now rather than holding the terminal')
962
1401
  }
963
1402
 
1403
+ const questions = pendingQuestions(pending)
964
1404
  const asked = await askAndWait(ctx, {
965
- title: 'A question from your agent',
966
- body: pending.question,
967
- ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
1405
+ title:
1406
+ ctx.config.project.value === null
1407
+ ? 'Question'
1408
+ : `Question · ${ctx.config.project.value}`,
1409
+ // A set is answered on the expanded card; the banner leads with the first
1410
+ // question and says how much more is waiting behind it.
1411
+ body:
1412
+ questions.length > 1
1413
+ ? `${questions[0]!.text} (+${questions.length - 1} more)`
1414
+ : pending.question,
1415
+ questions,
1416
+ ...(pending.detail !== undefined ? { detail: pending.detail } : {}),
968
1417
  event: 'agent_question',
969
1418
  session: sessionLabel(ctx, envelope),
970
1419
  // Outlives the block, and stays open on purpose: the answer is still
971
1420
  // useful to the next turn, which collects it with `notifai replies`.
972
1421
  windowSeconds: QUESTION_WINDOW_SECONDS,
973
1422
  onSubmitted: (live) => {
974
- // Re-read: the drain at the top of this hook rewrote the queue, and the
975
- // snapshot in `state` predates it.
976
- writeSessionState(sessionId, ctx.env, {
977
- ...readSessionState(sessionId, ctx.env),
978
- pending: {
979
- ...pending,
980
- request_id: live.requestId,
981
- collapse_key: live.collapseKey,
982
- device_ids: live.devices,
983
- },
1423
+ const livePending: PendingQuestion = {
1424
+ ...pending,
1425
+ request_id: live.requestId,
1426
+ collapse_key: live.collapseKey,
1427
+ device_ids: live.devices,
1428
+ }
1429
+ updateSessionState(sessionId, ctx.env, (current) => {
1430
+ const stillCurrent =
1431
+ current.pending?.question === pending.question &&
1432
+ current.pending?.asked_at === pending.asked_at &&
1433
+ current.pending?.request_id === undefined
1434
+ if (stillCurrent) return { ...current, pending: livePending }
1435
+
1436
+ // A newer `notifai ask` won while this submit was in flight. Keep it,
1437
+ // but now that request identifiers exist, preserve the old delivered
1438
+ // question as collectable/retirable instead of overwriting either one.
1439
+ const retirement = retiringQuestion(livePending, 'superseded')!
1440
+ const retiring = [...(current.retiring ?? [])]
1441
+ if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
1442
+ retiring.push(retirement)
1443
+ }
1444
+ return { ...current, retiring }
984
1445
  })
985
1446
  },
986
1447
  })
@@ -989,59 +1450,38 @@ async function escalate(
989
1450
  return { notes }
990
1451
  }
991
1452
 
992
- // What survives replacing the pending record. `retiring` has to: dropping it
993
- // is exactly the class of bug this guards against — a delivered notification
994
- // whose ids no longer exist anywhere.
995
- const current = readSessionState(sessionId, ctx.env)
996
- const carried = {
997
- ...(state.last_prompt_at !== undefined ? { last_prompt_at: state.last_prompt_at } : {}),
998
- ...(current.retiring !== undefined ? { retiring: current.retiring } : {}),
999
- }
1000
-
1001
1453
  if (!asked.reply) {
1002
1454
  // Keep the pending record so a returning user's UserPromptSubmit can retire
1003
1455
  // the notification that is still live on their devices. `request_id` being
1004
1456
  // set is also what stops the next Stop pushing the same question again.
1005
- writeSessionState(sessionId, ctx.env, {
1006
- ...carried,
1007
- pending: {
1008
- ...pending,
1009
- request_id: asked.requestId,
1010
- collapse_key: asked.collapseKey,
1011
- device_ids: asked.devices,
1012
- },
1013
- })
1014
- notes.push(
1015
- asked.degraded
1016
- ? `could not reach the server to find out whether you answered; check with: notifai replies ${asked.requestId}`
1017
- : `no answer in time; retrieve it later with: notifai replies ${asked.requestId}`,
1018
- )
1457
+ if (!asked.waited) {
1458
+ notes.push(
1459
+ `question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`,
1460
+ )
1461
+ } else if (asked.userReturned) {
1462
+ notes.push(
1463
+ `you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
1464
+ 'Retrieve it with: notifai replies --pending',
1465
+ )
1466
+ } else {
1467
+ notes.push(
1468
+ asked.degraded
1469
+ ? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
1470
+ : `no answer in time; retrieve it later with: notifai replies --pending`,
1471
+ )
1472
+ }
1019
1473
  return { notes }
1020
1474
  }
1021
1475
 
1022
- // Answered and closed — nothing left to retire.
1023
- writeSessionState(sessionId, ctx.env, carried)
1024
-
1025
- await retire(
1026
- ctx,
1027
- asked.collapseKey,
1028
- 'Answered',
1029
- asked.reply.text,
1030
- 'answered',
1031
- asked.requestId,
1032
- asked.devices,
1033
- sessionLabel(ctx, envelope),
1034
- )
1476
+ await finishPendingAnswer(ctx, envelope, sessionId, {
1477
+ ...pending,
1478
+ request_id: asked.requestId,
1479
+ collapse_key: asked.collapseKey,
1480
+ device_ids: asked.devices,
1481
+ }, asked.reply, true)
1035
1482
  notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
1036
1483
  return {
1037
- stdout: JSON.stringify({
1038
- decision: 'block',
1039
- // Harnesses render a Stop decision under an error-ish label, so this
1040
- // text must read as an answer on its own rather than as a failure.
1041
- reason:
1042
- `NotifAI — the user answered from ${asked.reply.device_name}: ` +
1043
- `"${asked.reply.text}". Continue with that answer.`,
1044
- }),
1484
+ stdout: stopAnswerOutput(asked.replies, pendingHasChoices(pending)),
1045
1485
  notes,
1046
1486
  }
1047
1487
  }
@@ -1066,6 +1506,7 @@ export function handleSessionEnd(
1066
1506
  const notes: string[] = []
1067
1507
  const sessionId = envelope.session_id
1068
1508
  if (!sessionId) return { notes }
1509
+ if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
1069
1510
 
1070
1511
  const state = readSessionState(sessionId, env)
1071
1512
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
@@ -1116,16 +1557,26 @@ export function registerQuestion(
1116
1557
  question: PendingQuestion,
1117
1558
  now: number = Date.now(),
1118
1559
  ): void {
1119
- const state = readSessionState(sessionId, env)
1120
- if (state.pending !== undefined) parkForRetirement(sessionId, env, state.pending, 'superseded')
1121
- // Re-read: parkForRetirement wrote the queue we must not clobber.
1122
- writeSessionState(sessionId, env, {
1123
- ...readSessionState(sessionId, env),
1124
- pending: {
1125
- asked_at: now,
1126
- ...question,
1127
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1128
- },
1560
+ updateSessionState(sessionId, env, (state) => {
1561
+ const retiring = [...(state.retiring ?? [])]
1562
+ if (state.pending !== undefined) {
1563
+ const retirement = retiringQuestion(state.pending, 'superseded')
1564
+ if (
1565
+ retirement !== null &&
1566
+ !retiring.some((entry) => entry.request_id === retirement.request_id)
1567
+ ) {
1568
+ retiring.push(retirement)
1569
+ }
1570
+ }
1571
+ return {
1572
+ ...state,
1573
+ ...(retiring.length > 0 ? { retiring } : {}),
1574
+ pending: {
1575
+ asked_at: now,
1576
+ ...question,
1577
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1578
+ },
1579
+ }
1129
1580
  })
1130
1581
  }
1131
1582