@raidiant/notifai 0.1.6 → 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/src/hooks.ts CHANGED
@@ -1,5 +1,15 @@
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
14
  import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
5
15
  import type { ApiClient } from './client.js'
@@ -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. */
@@ -217,15 +233,83 @@ export function writeSessionState(
217
233
  state: SessionState,
218
234
  ): void {
219
235
  const file = sessionStatePath(sessionId, env)
220
- mkdirSync(path.dirname(file), { recursive: true })
221
- writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
236
+ withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state))
222
237
  }
223
238
 
224
239
  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 })
240
+ const file = sessionStatePath(sessionId, env)
241
+ withFileLock(`${file}.lock`, () => {
242
+ rmSync(file, { force: true })
243
+ // The session override lives in a sibling file; leaving it behind meant a
244
+ // later session reusing the id silently inherited `ask_notifications = false`.
245
+ rmSync(sessionConfigPath(sessionId, env), { force: true })
246
+ })
247
+ }
248
+
249
+ function writeSessionStateUnlocked(file: string, state: SessionState): void {
250
+ atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
251
+ }
252
+
253
+ function updateSessionState(
254
+ sessionId: string,
255
+ env: NodeJS.ProcessEnv,
256
+ update: (current: SessionState) => SessionState,
257
+ ): SessionState {
258
+ const file = sessionStatePath(sessionId, env)
259
+ return withFileLock(`${file}.lock`, () => {
260
+ const next = update(readSessionState(sessionId, env))
261
+ writeSessionStateUnlocked(file, next)
262
+ return next
263
+ })
264
+ }
265
+
266
+ /**
267
+ * Serialize a short read-modify-write without ever exposing partial JSON.
268
+ * A crashed holder is recoverable after 30 seconds; releases compare their
269
+ * random token under the same cooperating lock discipline before unlinking.
270
+ */
271
+ const FILE_LOCK_STALE_MS = 30_000
272
+ const FILE_LOCK_WAIT_MS = 1_000
273
+ const FILE_LOCK_POLL_MS = 5
274
+ const lockSleep = new Int32Array(new SharedArrayBuffer(4))
275
+
276
+ function withFileLock<T>(file: string, action: () => T): T {
277
+ mkdirSync(path.dirname(file), { recursive: true })
278
+ const token = randomBytes(12).toString('base64url')
279
+ const deadline = Date.now() + FILE_LOCK_WAIT_MS
280
+ for (;;) {
281
+ try {
282
+ const handle = openSync(file, 'wx', 0o600)
283
+ try {
284
+ writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
285
+ } finally {
286
+ closeSync(handle)
287
+ }
288
+ break
289
+ } catch (err) {
290
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
291
+ let stale = false
292
+ try {
293
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
294
+ stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
295
+ } catch {
296
+ stale = true
297
+ }
298
+ if (stale) rmSync(file, { force: true })
299
+ else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
300
+ else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
301
+ }
302
+ }
303
+ try {
304
+ return action()
305
+ } finally {
306
+ try {
307
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
308
+ if (held.token === token) rmSync(file, { force: true })
309
+ } catch {
310
+ // A replaced or externally removed lock is no longer ours to release.
311
+ }
312
+ }
229
313
  }
230
314
 
231
315
  /**
@@ -288,52 +372,101 @@ export function pruneAbandonedSessions(
288
372
  * pending question, see no `request_id`, and both escalate: one question, two
289
373
  * notifications.
290
374
  *
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.
375
+ * Exclusive create is atomic on POSIX, so exactly one process gets the claim
376
+ * and the other steps aside. A short guard serializes stale replacement with
377
+ * contenders and releases; random ownership tokens keep an old holder from
378
+ * unlinking its replacement. A claim older than the hook budget is a crashed
379
+ * process rather than a live one and is broken, avoiding permanent suppression.
296
380
  */
297
381
  const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
382
+ const heldClaims = new Map<string, string>()
298
383
 
299
384
  export function claimQuestionPush(
300
385
  sessionId: string,
301
386
  env: NodeJS.ProcessEnv,
302
387
  now: number = Date.now(),
388
+ beforeStaleReplace?: () => void,
303
389
  ): boolean {
304
390
  const file = claimPath(sessionId, env)
391
+ const guard = `${file}.guard`
392
+ const token = randomBytes(12).toString('base64url')
305
393
  mkdirSync(path.dirname(file), { recursive: true })
394
+ if (!acquireClaimGuard(guard)) return false
306
395
  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
- 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
396
  try {
323
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
397
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
324
398
  mode: 0o600,
325
399
  flag: 'wx',
326
400
  })
401
+ heldClaims.set(file, token)
327
402
  return true
328
403
  } catch {
329
- // Someone else broke it first and won. One of us proceeding is the point.
330
- return false
404
+ // Held. Break it only if whoever holds it cannot still be running.
405
+ try {
406
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
407
+ const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
408
+ if (age >= 0 && age < CLAIM_TTL_MS) return false
409
+ } catch {
410
+ // Unreadable or corrupt: treat as abandoned.
411
+ }
412
+ beforeStaleReplace?.()
413
+ rmSync(file, { force: true })
414
+ try {
415
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
416
+ mode: 0o600,
417
+ flag: 'wx',
418
+ })
419
+ heldClaims.set(file, token)
420
+ return true
421
+ } catch {
422
+ return false
423
+ }
331
424
  }
425
+ } finally {
426
+ rmSync(guard, { force: true })
332
427
  }
333
428
  }
334
429
 
335
430
  export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
336
- rmSync(claimPath(sessionId, env), { force: true })
431
+ const file = claimPath(sessionId, env)
432
+ const token = heldClaims.get(file)
433
+ if (token === undefined) return
434
+ const guard = `${file}.guard`
435
+ if (!acquireClaimGuard(guard)) {
436
+ heldClaims.delete(file)
437
+ return
438
+ }
439
+ try {
440
+ try {
441
+ const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
442
+ if (held.token === token) rmSync(file, { force: true })
443
+ } catch {
444
+ // Missing or replaced claims are not ours to release.
445
+ }
446
+ } finally {
447
+ heldClaims.delete(file)
448
+ rmSync(guard, { force: true })
449
+ }
450
+ }
451
+
452
+ /** The guard serializes stale replacement and release; recover a crashed guard. */
453
+ function acquireClaimGuard(file: string): boolean {
454
+ for (let attempt = 0; attempt < 2; attempt += 1) {
455
+ try {
456
+ const handle = openSync(file, 'wx', 0o600)
457
+ closeSync(handle)
458
+ return true
459
+ } catch (err) {
460
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
461
+ try {
462
+ if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS) return false
463
+ } catch {
464
+ // A vanished/corrupt guard is safe to retry once.
465
+ }
466
+ rmSync(file, { force: true })
467
+ }
468
+ }
469
+ return false
337
470
  }
338
471
 
339
472
  function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
@@ -354,14 +487,22 @@ export function writeProjectSession(
354
487
  env: NodeJS.ProcessEnv,
355
488
  sessionId: string,
356
489
  now: number,
490
+ harness: HookHarness,
357
491
  ): void {
358
492
  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,
493
+ withFileLock(`${file}.lock`, () => {
494
+ atomicWriteFileSync(
495
+ file,
496
+ `${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`,
497
+ )
362
498
  })
363
499
  }
364
500
 
501
+ export interface ProjectSessionPointer {
502
+ sessionId: string
503
+ harness: HookHarness
504
+ }
505
+
365
506
  /**
366
507
  * Resolves the session working in `cwd`. Stale pointers are ignored rather than
367
508
  * trusted: an id left by a session that ended days ago would send the question
@@ -373,21 +514,57 @@ export function readProjectSession(
373
514
  now: number,
374
515
  maxAgeMs = 24 * 3600 * 1000,
375
516
  ): string | null {
517
+ return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
518
+ }
519
+
520
+ export function readProjectSessionPointer(
521
+ cwd: string,
522
+ env: NodeJS.ProcessEnv,
523
+ now: number,
524
+ maxAgeMs = 24 * 3600 * 1000,
525
+ ): ProjectSessionPointer | null {
376
526
  const file = projectSessionPointerPath(cwd, env)
377
527
  if (!existsSync(file)) return null
378
528
  try {
379
529
  const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
380
530
  session_id?: unknown
381
531
  updated_at?: unknown
532
+ harness?: unknown
382
533
  }
383
534
  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
535
+ if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
536
+ if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
537
+ // A pointer is routing evidence only while its session state still exists
538
+ // and parses. SessionEnd and explicit cleanup therefore invalidate it even
539
+ // when a crash prevented the pointer file itself from being removed.
540
+ const sessionFile = sessionStatePath(parsed.session_id, env)
541
+ if (!existsSync(sessionFile)) return null
542
+ const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
543
+ if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
544
+ return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
386
545
  } catch {
387
546
  return null
388
547
  }
389
548
  }
390
549
 
550
+ const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
551
+
552
+ function clearMatchingProjectSession(
553
+ cwd: string,
554
+ env: NodeJS.ProcessEnv,
555
+ sessionId: string,
556
+ ): void {
557
+ const file = projectSessionPointerPath(cwd, env)
558
+ withFileLock(`${file}.lock`, () => {
559
+ try {
560
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: unknown }
561
+ if (parsed.session_id === sessionId) rmSync(file, { force: true })
562
+ } catch {
563
+ // Missing/corrupt is already not a live pointer.
564
+ }
565
+ })
566
+ }
567
+
391
568
  /**
392
569
  * Absent means the user is not at this machine's keyboard.
393
570
  *
@@ -455,8 +632,12 @@ export interface HookContext {
455
632
  requestId: string,
456
633
  timeoutSeconds: number,
457
634
  ) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
635
+ /** OpenCode cannot consume Stop stdout, so its adapter must never wait there. */
636
+ harness?: HookHarness
458
637
  }
459
638
 
639
+ export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
640
+
460
641
  interface AskResult {
461
642
  requestId: string
462
643
  collapseKey: string
@@ -465,8 +646,15 @@ interface AskResult {
465
646
  devices: string[]
466
647
  /** The wait ended amid network failures, so "no answer" is unproven. */
467
648
  degraded: boolean
649
+ /** False when the harness cannot consume a Stop answer and polling was skipped. */
650
+ waited: boolean
651
+ /** The user returned to this machine after the question was pushed. */
652
+ userReturned: boolean
468
653
  }
469
654
 
655
+ /** Re-check local presence at this cadence while a pushed question is waiting. */
656
+ const REPLY_PRESENCE_POLL_SECONDS = 5
657
+
470
658
  /**
471
659
  * Push a question and block for the answer.
472
660
  *
@@ -509,7 +697,6 @@ async function askAndWait(
509
697
  replyWindow: Math.max(60, options.windowSeconds),
510
698
  ...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
511
699
  collapseKey,
512
- level: 'time_sensitive',
513
700
  })
514
701
  if (!build.ok) return { error: build.error }
515
702
 
@@ -526,14 +713,62 @@ async function askAndWait(
526
713
  collapseKey,
527
714
  devices: answerable,
528
715
  })
529
- const result = await ctx.waitForFirstReply(receipt.request_id, timeoutSeconds)
716
+ if (ctx.harness === 'opencode') {
717
+ return {
718
+ requestId: receipt.request_id,
719
+ collapseKey,
720
+ reply: null,
721
+ devices: answerable,
722
+ degraded: false,
723
+ waited: false,
724
+ userReturned: false,
725
+ }
726
+ }
727
+
728
+ const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds)
530
729
  if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
531
730
  return {
532
731
  requestId: receipt.request_id,
533
732
  collapseKey,
534
733
  reply: result.replies[0] ?? null,
535
734
  devices: answerable,
536
- degraded: result.degraded === true,
735
+ degraded: result.degraded,
736
+ waited: true,
737
+ userReturned: result.userReturned,
738
+ }
739
+ }
740
+
741
+ async function waitForReplyWhileAway(
742
+ ctx: HookContext,
743
+ requestId: string,
744
+ timeoutSeconds: number,
745
+ ): Promise<{ replies: ReplyView[]; degraded: boolean; userReturned: boolean }> {
746
+ const deadline = ctx.now() + timeoutSeconds * 1000
747
+ let degraded = false
748
+ let firstPoll = true
749
+
750
+ for (;;) {
751
+ if (ctx.config.require_idle.value) {
752
+ const idle = ctx.idleSeconds()
753
+ if (idle !== null && idle < ctx.config.away_after_seconds.value) {
754
+ return { replies: [], degraded, userReturned: true }
755
+ }
756
+ }
757
+
758
+ const remainingMs = Math.max(0, deadline - ctx.now())
759
+ if (!firstPoll && remainingMs === 0) {
760
+ return { replies: [], degraded, userReturned: false }
761
+ }
762
+ firstPoll = false
763
+ const pollSeconds = Math.min(
764
+ REPLY_PRESENCE_POLL_SECONDS,
765
+ Math.max(0, Math.ceil(remainingMs / 1000)),
766
+ )
767
+ const result = await ctx.waitForFirstReply(requestId, pollSeconds)
768
+ degraded ||= result.degraded === true
769
+ if (result.replies.length > 0) {
770
+ return { replies: result.replies, degraded, userReturned: false }
771
+ }
537
772
  }
538
773
  }
539
774
 
@@ -655,12 +890,17 @@ export function parkForRetirement(
655
890
  ): void {
656
891
  const retirement = retiringQuestion(pending, state)
657
892
  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],
893
+ updateSessionState(sessionId, env, (current) => {
894
+ const already = current.retiring ?? []
895
+ const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
896
+ if (existing < 0) return { ...current, retiring: [...already, retirement] }
897
+ const retiring = [...already]
898
+ // An observed answer is final truth and upgrades an earlier supersession
899
+ // parked while the submission callback was racing a newer question.
900
+ if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
901
+ retiring[existing] = retirement
902
+ }
903
+ return { ...current, retiring }
664
904
  })
665
905
  }
666
906
 
@@ -718,7 +958,6 @@ export async function drainRetirements(
718
958
  if (queue.length === 0) return []
719
959
 
720
960
  const retired: string[] = []
721
- const remaining: RetiringQuestion[] = []
722
961
  for (const entry of queue) {
723
962
  await closeQuietly(ctx, entry.request_id)
724
963
  const sent = await retire(
@@ -731,17 +970,18 @@ export async function drainRetirements(
731
970
  retirementDeviceIds(entry),
732
971
  session,
733
972
  )
734
- if (sent) retired.push(entry.request_id)
735
- else remaining.push(entry)
973
+ if (sent) {
974
+ retired.push(entry.request_id)
975
+ // Persist each success before touching the next entry. A later corrupt
976
+ // record or interrupted process must not resurrect work already done.
977
+ updateSessionState(sessionId, env, (current) => ({
978
+ ...current,
979
+ retiring: (current.retiring ?? []).filter(
980
+ (candidate) => candidate.request_id !== entry.request_id,
981
+ ),
982
+ }))
983
+ }
736
984
  }
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
985
  return retired
746
986
  }
747
987
 
@@ -761,10 +1001,20 @@ function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
761
1001
  }
762
1002
  }
763
1003
 
764
- function writeOrphanQueue(env: NodeJS.ProcessEnv, queue: OrphanRetirement[]): void {
1004
+ function writeOrphanQueueUnlocked(file: string, queue: OrphanRetirement[]): void {
1005
+ atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`)
1006
+ }
1007
+
1008
+ function updateOrphanQueue(
1009
+ env: NodeJS.ProcessEnv,
1010
+ update: (current: OrphanRetirement[]) => OrphanRetirement[],
1011
+ ): OrphanRetirement[] {
765
1012
  const file = orphanQueuePath(env)
766
- mkdirSync(path.dirname(file), { recursive: true })
767
- writeFileSync(file, `${JSON.stringify(queue, null, 2)}\n`, { mode: 0o600 })
1013
+ return withFileLock(`${file}.lock`, () => {
1014
+ const next = update(readOrphanQueue(env))
1015
+ writeOrphanQueueUnlocked(file, next)
1016
+ return next
1017
+ })
768
1018
  }
769
1019
 
770
1020
  /** Move retirements into the global queue; deduped so a retry costs nothing. */
@@ -775,13 +1025,17 @@ export function orphanRetirements(
775
1025
  now: number,
776
1026
  ): void {
777
1027
  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))
1028
+ updateOrphanQueue(env, (queue) => {
1029
+ const known = new Set(queue.map((entry) => entry.request_id))
1030
+ const added = entries
1031
+ .filter((entry) => !known.has(entry.request_id))
1032
+ .map((entry) => ({
1033
+ ...entry,
1034
+ ...(session !== undefined ? { session } : {}),
1035
+ enqueued_at: now,
1036
+ }))
1037
+ return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
1038
+ })
785
1039
  }
786
1040
 
787
1041
  /**
@@ -803,6 +1057,9 @@ export async function drainOrphanRetirements(
803
1057
  // and cheap, so treat it as due rather than letting it linger for ever.
804
1058
  if (age > ORPHAN_TTL_MS) {
805
1059
  done.push(entry.request_id)
1060
+ updateOrphanQueue(env, (current) =>
1061
+ current.filter((candidate) => candidate.request_id !== entry.request_id),
1062
+ )
806
1063
  continue
807
1064
  }
808
1065
  await closeQuietly(ctx, entry.request_id)
@@ -816,17 +1073,103 @@ export async function drainOrphanRetirements(
816
1073
  retirementDeviceIds(entry),
817
1074
  entry.session,
818
1075
  )
819
- if (sent) done.push(entry.request_id)
1076
+ if (sent) {
1077
+ done.push(entry.request_id)
1078
+ updateOrphanQueue(env, (current) =>
1079
+ current.filter((candidate) => candidate.request_id !== entry.request_id),
1080
+ )
1081
+ }
820
1082
  }
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
1083
  return done
828
1084
  }
829
1085
 
1086
+ const LATE_STOP_POLL_SECONDS = 4
1087
+ const LATE_PROMPT_POLL_SECONDS = 3
1088
+ const MAX_CONTINUATION_COUNT = 3
1089
+
1090
+ interface PendingPoll {
1091
+ reply: ReplyView | null
1092
+ degraded: boolean
1093
+ failed: boolean
1094
+ }
1095
+
1096
+ /** One bounded recovery poll for a question that outlived its original wait. */
1097
+ async function pollPendingReply(
1098
+ ctx: HookContext,
1099
+ pending: PendingQuestion,
1100
+ timeoutSeconds: number,
1101
+ ): Promise<PendingPoll> {
1102
+ if (pending.request_id === undefined) return { reply: null, degraded: false, failed: false }
1103
+ try {
1104
+ const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
1105
+ return {
1106
+ reply: result.replies[0] ?? null,
1107
+ degraded: result.degraded === true,
1108
+ failed: false,
1109
+ }
1110
+ } catch {
1111
+ return { reply: null, degraded: true, failed: true }
1112
+ }
1113
+ }
1114
+
1115
+ function answerContext(reply: ReplyView): string {
1116
+ return `Notifai — the user answered from ${reply.device_name}: "${reply.text}". Continue with that answer.`
1117
+ }
1118
+
1119
+ function userPromptAnswerOutput(reply: ReplyView): string {
1120
+ return JSON.stringify({
1121
+ hookSpecificOutput: {
1122
+ hookEventName: 'UserPromptSubmit',
1123
+ additionalContext: answerContext(reply),
1124
+ },
1125
+ })
1126
+ }
1127
+
1128
+ function stopAnswerOutput(reply: ReplyView): string {
1129
+ return JSON.stringify({ decision: 'block', reason: answerContext(reply) })
1130
+ }
1131
+
1132
+ /**
1133
+ * Close, truthfully retire, and forget one answered pending question without
1134
+ * dropping any other retirement debt. Stop answers also open one bounded
1135
+ * continuation generation; UserPromptSubmit answers ride the user's new turn.
1136
+ */
1137
+ async function finishPendingAnswer(
1138
+ ctx: HookContext,
1139
+ envelope: HookEnvelope,
1140
+ sessionId: string,
1141
+ pending: PendingQuestion,
1142
+ reply: ReplyView,
1143
+ continuation: boolean,
1144
+ ): Promise<void> {
1145
+ const retirement = retiringQuestion(pending, 'answered')
1146
+ updateSessionState(sessionId, ctx.env, (current) => {
1147
+ const retiring = [...(current.retiring ?? [])]
1148
+ if (retirement !== null) {
1149
+ const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
1150
+ if (existing < 0) retiring.push(retirement)
1151
+ else retiring[existing] = retirement
1152
+ }
1153
+ const samePending =
1154
+ current.pending?.question === pending.question &&
1155
+ current.pending?.asked_at === pending.asked_at &&
1156
+ current.pending?.request_id === pending.request_id
1157
+ const next = { ...current, retiring }
1158
+ if (samePending) delete next.pending
1159
+ if (continuation) {
1160
+ next.continuation = {
1161
+ answered_at: ctx.now(),
1162
+ count:
1163
+ (envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
1164
+ }
1165
+ } else {
1166
+ next.last_prompt_at = ctx.now()
1167
+ }
1168
+ return next
1169
+ })
1170
+ await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1171
+ }
1172
+
830
1173
  // ---------------------------------------------------------------------------
831
1174
  // UserPromptSubmit — the user is at the keyboard
832
1175
  // ---------------------------------------------------------------------------
@@ -847,18 +1190,43 @@ export async function handleUserPromptSubmit(
847
1190
 
848
1191
  const state = readSessionState(sessionId, ctx.env)
849
1192
  const pending = state.pending
1193
+ if (pending?.request_id !== undefined) {
1194
+ const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS)
1195
+ if (late.reply !== null) {
1196
+ await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false)
1197
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1198
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1199
+ }
1200
+ await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1201
+ notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1202
+ return { stdout: userPromptAnswerOutput(late.reply), notes }
1203
+ }
1204
+ if (late.failed || late.degraded) {
1205
+ notes.push('could not check the pending question for a late answer before the prompt')
1206
+ }
1207
+ }
850
1208
  // Park before dropping `pending`. If the process dies between these writes,
851
1209
  // the next hook sees both copies and dedupes them; the old order could die in
852
1210
  // 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 } : {}),
1211
+ updateSessionState(sessionId, ctx.env, (current) => {
1212
+ const retiring = [...(current.retiring ?? [])]
1213
+ if (current.pending !== undefined) {
1214
+ const retirement = retiringQuestion(current.pending, 'answered_elsewhere')
1215
+ if (
1216
+ retirement !== null &&
1217
+ !retiring.some((entry) => entry.request_id === retirement.request_id)
1218
+ ) {
1219
+ retiring.push(retirement)
1220
+ }
1221
+ }
1222
+ return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) }
858
1223
  })
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())
1224
+ // The bridge that lets a plain `notifai ask` find the hook's canonical
1225
+ // session: an agent shell command gets no hook payload, and not every
1226
+ // harness exports an id in the same shape.
1227
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1228
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1229
+ }
862
1230
 
863
1231
  const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
864
1232
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
@@ -903,22 +1271,46 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
903
1271
  notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
904
1272
  }
905
1273
 
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
1274
  const state = readSessionState(sessionId, ctx.env)
914
1275
  const pending = state.pending
915
1276
  if (!pending) return { notes }
916
1277
  if (pending.request_id !== undefined) {
1278
+ const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS)
1279
+ if (late.reply !== null) {
1280
+ await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
1281
+ notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
1282
+ return { stdout: stopAnswerOutput(late.reply), notes }
1283
+ }
917
1284
  // Already live on the user's devices from an earlier Stop; asking twice for
918
1285
  // one question is the nagging failure this feature exists to avoid.
919
- notes.push(`already asked (${pending.request_id}); waiting for that answer`)
1286
+ notes.push(
1287
+ late.failed || late.degraded
1288
+ ? `already asked (${pending.request_id}); could not check whether its answer arrived`
1289
+ : `already asked (${pending.request_id}); waiting for that answer`,
1290
+ )
920
1291
  return { notes }
921
1292
  }
1293
+
1294
+ // A Stop answer may immediately produce a legitimate follow-up question.
1295
+ // Allow that new generation, but never re-run an old pending question and
1296
+ // never let answer continuations become an unbounded agent loop.
1297
+ if (envelope.stop_hook_active === true) {
1298
+ const continuation = state.continuation
1299
+ const isNew =
1300
+ continuation !== undefined &&
1301
+ pending.asked_at !== undefined &&
1302
+ pending.asked_at > continuation.answered_at
1303
+ if (!isNew) {
1304
+ notes.push('already continuing from an answer; not asking again this turn')
1305
+ return { notes }
1306
+ }
1307
+ if (continuation.count >= MAX_CONTINUATION_COUNT) {
1308
+ notes.push(
1309
+ `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
1310
+ )
1311
+ return { notes }
1312
+ }
1313
+ }
922
1314
  if (!ctx.config.ask_notifications.value) return { notes }
923
1315
  if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
924
1316
  notes.push('you are at the keyboard; leaving the question in the terminal')
@@ -935,7 +1327,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
935
1327
  return { notes }
936
1328
  }
937
1329
  try {
938
- return await escalate(ctx, envelope, sessionId, state, pending, notes)
1330
+ return await escalate(ctx, envelope, sessionId, pending, notes)
939
1331
  } finally {
940
1332
  releaseQuestionPush(sessionId, ctx.env)
941
1333
  }
@@ -946,7 +1338,6 @@ async function escalate(
946
1338
  ctx: HookContext,
947
1339
  envelope: HookEnvelope,
948
1340
  sessionId: string,
949
- state: SessionState,
950
1341
  pending: PendingQuestion,
951
1342
  notes: string[],
952
1343
  ): Promise<HookOutcome> {
@@ -962,7 +1353,10 @@ async function escalate(
962
1353
  }
963
1354
 
964
1355
  const asked = await askAndWait(ctx, {
965
- title: 'A question from your agent',
1356
+ title:
1357
+ ctx.config.project.value === null
1358
+ ? 'Question'
1359
+ : `Question · ${ctx.config.project.value}`,
966
1360
  body: pending.question,
967
1361
  ...(pending.choices !== undefined ? { choices: pending.choices } : {}),
968
1362
  event: 'agent_question',
@@ -971,16 +1365,28 @@ async function escalate(
971
1365
  // useful to the next turn, which collects it with `notifai replies`.
972
1366
  windowSeconds: QUESTION_WINDOW_SECONDS,
973
1367
  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
- },
1368
+ const livePending: PendingQuestion = {
1369
+ ...pending,
1370
+ request_id: live.requestId,
1371
+ collapse_key: live.collapseKey,
1372
+ device_ids: live.devices,
1373
+ }
1374
+ updateSessionState(sessionId, ctx.env, (current) => {
1375
+ const stillCurrent =
1376
+ current.pending?.question === pending.question &&
1377
+ current.pending?.asked_at === pending.asked_at &&
1378
+ current.pending?.request_id === undefined
1379
+ if (stillCurrent) return { ...current, pending: livePending }
1380
+
1381
+ // A newer `notifai ask` won while this submit was in flight. Keep it,
1382
+ // but now that request identifiers exist, preserve the old delivered
1383
+ // question as collectable/retirable instead of overwriting either one.
1384
+ const retirement = retiringQuestion(livePending, 'superseded')!
1385
+ const retiring = [...(current.retiring ?? [])]
1386
+ if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
1387
+ retiring.push(retirement)
1388
+ }
1389
+ return { ...current, retiring }
984
1390
  })
985
1391
  },
986
1392
  })
@@ -989,59 +1395,38 @@ async function escalate(
989
1395
  return { notes }
990
1396
  }
991
1397
 
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
1398
  if (!asked.reply) {
1002
1399
  // Keep the pending record so a returning user's UserPromptSubmit can retire
1003
1400
  // the notification that is still live on their devices. `request_id` being
1004
1401
  // 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
- )
1402
+ if (!asked.waited) {
1403
+ notes.push(
1404
+ `question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`,
1405
+ )
1406
+ } else if (asked.userReturned) {
1407
+ notes.push(
1408
+ `you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
1409
+ 'Retrieve it with: notifai replies --pending',
1410
+ )
1411
+ } else {
1412
+ notes.push(
1413
+ asked.degraded
1414
+ ? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
1415
+ : `no answer in time; retrieve it later with: notifai replies --pending`,
1416
+ )
1417
+ }
1019
1418
  return { notes }
1020
1419
  }
1021
1420
 
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
- )
1421
+ await finishPendingAnswer(ctx, envelope, sessionId, {
1422
+ ...pending,
1423
+ request_id: asked.requestId,
1424
+ collapse_key: asked.collapseKey,
1425
+ device_ids: asked.devices,
1426
+ }, asked.reply, true)
1035
1427
  notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
1036
1428
  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
- }),
1429
+ stdout: stopAnswerOutput(asked.reply),
1045
1430
  notes,
1046
1431
  }
1047
1432
  }
@@ -1066,6 +1451,7 @@ export function handleSessionEnd(
1066
1451
  const notes: string[] = []
1067
1452
  const sessionId = envelope.session_id
1068
1453
  if (!sessionId) return { notes }
1454
+ if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
1069
1455
 
1070
1456
  const state = readSessionState(sessionId, env)
1071
1457
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
@@ -1116,16 +1502,26 @@ export function registerQuestion(
1116
1502
  question: PendingQuestion,
1117
1503
  now: number = Date.now(),
1118
1504
  ): 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
- },
1505
+ updateSessionState(sessionId, env, (state) => {
1506
+ const retiring = [...(state.retiring ?? [])]
1507
+ if (state.pending !== undefined) {
1508
+ const retirement = retiringQuestion(state.pending, 'superseded')
1509
+ if (
1510
+ retirement !== null &&
1511
+ !retiring.some((entry) => entry.request_id === retirement.request_id)
1512
+ ) {
1513
+ retiring.push(retirement)
1514
+ }
1515
+ }
1516
+ return {
1517
+ ...state,
1518
+ ...(retiring.length > 0 ? { retiring } : {}),
1519
+ pending: {
1520
+ asked_at: now,
1521
+ ...question,
1522
+ question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1523
+ },
1524
+ }
1129
1525
  })
1130
1526
  }
1131
1527