@raidiant/notifai 0.4.0 → 0.5.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
@@ -36,7 +36,12 @@ import { buildDraft } from './send.js'
36
36
  import { atomicWriteFileSync } from './atomic-file.js'
37
37
  import { withFileLock } from './file-lock.js'
38
38
  import type { Logger } from './logging.js'
39
- import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING, type Harness } from './harnesses.js'
39
+ import {
40
+ HARNESS_CAPABILITIES,
41
+ HARNESSES,
42
+ type DeliveryRoute,
43
+ type Harness,
44
+ } from './harnesses.js'
40
45
 
41
46
  /**
42
47
  * Harness hook handlers.
@@ -46,10 +51,13 @@ import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING, type Harness } from './ha
46
51
  * and Codex can continue directly from a turn-end answer. Harnesses without a
47
52
  * proven exact-session continuation fail closed at question admission.
48
53
  *
49
- * The load-bearing constraint is that these hooks are synchronous: while one
50
- * blocks, the harness cannot show its own prompt either. So a hook may only
51
- * take over when the user is demonstrably absent. Present user, or no evidence
52
- * either way, means exit immediately and let the terminal do its job.
54
+ * Where the user is standing no longer decides anything here. It used to: the
55
+ * turn-end hook held the terminal for as long as it waited, so waiting was
56
+ * only defensible while the person was demonstrably not at the keyboard. On
57
+ * Claude Code the handler now returns instantly and the waiter runs out of
58
+ * band, and nothing is monopolised in the first place — so the question is
59
+ * simply whether the user wants to be reached, which they answer once in
60
+ * configuration rather than implicitly with every keystroke.
53
61
  */
54
62
 
55
63
  /** Fields we read from harness hook JSON. Everything else is passed through. */
@@ -94,16 +102,20 @@ export interface SessionState {
94
102
  * costs nothing and a missed one costs a stale notification for ever.
95
103
  */
96
104
  retiring?: RetiringQuestion[]
97
- /** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
105
+ /**
106
+ * Tracks bounded Stop continuations so a follow-up ask is delivered once.
107
+ * `count` is how many answer generations have run consecutively without the
108
+ * user taking a turn themselves; their next prompt starts it over.
109
+ */
98
110
  continuation?: {
99
111
  answered_at: number
100
112
  count: number
101
113
  }
102
114
  /**
103
- * A phone answer durably captured but not yet acknowledged by a successor
104
- * hook. It stays here until the next Stop proves the harness processed the
105
- * continuation; a crash before stdout therefore replays instead of erasing
106
- * the user's answer.
115
+ * A phone answer durably captured but not yet acknowledged. It stays here
116
+ * until a delivery is acknowledged — by the route's own write, or by the
117
+ * successor Stop of a blocking continuation — so a crash before the answer
118
+ * reaches the harness replays it instead of erasing it.
107
119
  */
108
120
  accepted?: AcceptedAnswerDelivery
109
121
  }
@@ -112,7 +124,20 @@ interface AcceptedAnswerDelivery {
112
124
  answers: AnsweredPending[]
113
125
  remaining: number
114
126
  recorded_at: number
115
- continuation_count?: number
127
+ /**
128
+ * Epoch ms when a route's own write handed this answer to the harness, with
129
+ * the route that performed it. Recorded so that "this answer was delivered"
130
+ * is a fact on the journal rather than something inferred from a harness flag
131
+ * that only one route ever sets.
132
+ */
133
+ delivered_at?: number
134
+ delivered_route?: string
135
+ /**
136
+ * How many times this answer has been handed to any route. The route-agnostic
137
+ * loop backstop: bounded by `MAX_CONTINUATION_COUNT` in one place every route
138
+ * passes through.
139
+ */
140
+ delivery_attempts?: number
116
141
  }
117
142
 
118
143
  /** A delivered question awaiting its retirement push. */
@@ -203,69 +228,50 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
203
228
  return { ids, display: ids.join(', ') }
204
229
  }
205
230
 
206
- /** How often the grace window rechecks whether the user has come back. */
207
- const GRACE_POLL_MS = 5_000
208
-
209
231
  /**
210
- * Total seconds the complete Stop invocation may own. Generated adapters grant
211
- * another minute of host headroom, because a killed hook can lose an answer
212
- * the user has already given. Grace and reply waiting share this budget while
213
- * the reply retains a small actionable window and finalization reserve.
232
+ * The waiter's whole wall clock: the terminal-first timer, submission, and the
233
+ * wait for an answer, from the moment the turn-end hook starts.
234
+ *
235
+ * One plain ceiling, deliberately not a budget carved into reserves. The
236
+ * reserves existed to fit every phase inside a *held* turn; on Claude Code the
237
+ * turn is not held any more, and on a host that still holds one the useful
238
+ * question is only how long that host is willing to wait in total.
214
239
  */
215
- const STOP_BUDGET_SECONDS = HOOK_TIMING.totalSeconds
216
- const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds
240
+ export const WAITER_CEILING_SECONDS = 480
217
241
 
218
- export type GraceOutcome =
219
- /** The window elapsed with the user still gone; escalate. */
220
- | 'absent'
221
- /** The user touched the machine; the terminal is theirs. */
222
- | 'user-returned'
223
- /** No idle source, so waiting would hold a terminal we cannot monitor. */
224
- | 'no-signal'
242
+ /**
243
+ * The wire contract's shortest reply window. A question submitted with less
244
+ * than this is rejected outright, and accepting one would in any case let the
245
+ * server go on taking an answer after the waiter that owns it has returned.
246
+ */
247
+ const MIN_REPLY_WINDOW_SECONDS = 60
225
248
 
226
249
  /**
227
250
  * The terminal-first wait: the question sits in the terminal for
228
251
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
229
252
  * devices.
230
253
  *
231
- * Holding a blocking Stop hook open is normally hostile — while it blocks, the
232
- * harness cannot show its prompt either, so a user wanting to answer locally is
233
- * locked out. What makes it safe is that the wait is abandoned the moment the
234
- * user touches the keyboard or mouse. The block therefore only ever persists
235
- * while they are demonstrably not using the machine, which costs them nothing.
236
- *
237
- * That safety depends entirely on the idle signal, so with no idle source this
238
- * refuses to wait at all rather than holding a terminal it cannot monitor.
254
+ * A plain timer, and nothing else. It once polled an idle signal so it could
255
+ * abandon the wait the moment the user touched the keyboard — necessary while
256
+ * the wait blocked the terminal, because a user wanting to answer locally was
257
+ * otherwise locked out of their own prompt. Nothing is monopolised now, so
258
+ * there is nothing to watch for and no machine this cannot run on.
239
259
  *
240
- * With `require_idle` off the whole calculus changes: the user has said they
241
- * want the question to reach them whether or not they are at the keyboard, so
242
- * there is nothing to watch for and this becomes what its name always claimed —
243
- * a plain timer. It then works on machines with no idle source too.
260
+ * The window is measured from registration, not from the turn's end: a
261
+ * question the agent asked five minutes ago while it kept working has already
262
+ * served its wait. A stamp from the future would otherwise consume the whole
263
+ * ceiling and leave no room to answer, so it starts no later than now.
244
264
  */
245
- export async function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome> {
246
- const threshold = ctx.config.away_after_seconds.value
247
- // Keep a real device-answer window even when a user selects maximum grace.
248
- const graceSeconds = Math.min(
249
- ctx.config.ask_grace_seconds.value,
250
- HOOK_TIMING.maxGraceSeconds,
251
- )
252
- // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
253
- // future would wait far past the hook's budget, one from the distant past
254
- // would skip the terminal-first window the user asked for entirely. Anything
255
- // outside a plausible range restarts the window from now.
256
- const elapsed = ctx.now() - askedAt
257
- const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now()
258
- const deadline = start + graceSeconds * 1000
259
-
260
- for (;;) {
261
- if (ctx.config.require_idle.value) {
262
- const idle = ctx.idleSeconds()
263
- if (idle === null) return 'no-signal'
264
- if (idle < threshold) return 'user-returned'
265
- }
266
- if (ctx.now() >= deadline) return 'absent'
267
- await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())))
268
- }
265
+ async function awaitTerminalFirstWindow(
266
+ ctx: HookContext,
267
+ askedAt: number,
268
+ ceilingAt: number,
269
+ ): Promise<void> {
270
+ const graceSeconds = ctx.config.ask_grace_seconds.value
271
+ if (graceSeconds <= 0) return
272
+ const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt)
273
+ const remaining = until - ctx.now()
274
+ if (remaining > 0) await ctx.sleep(remaining)
269
275
  }
270
276
 
271
277
  export interface HookOutcome {
@@ -277,6 +283,59 @@ export interface HookOutcome {
277
283
  log?: Record<string, unknown>
278
284
  }
279
285
 
286
+ /** An accepted continuation ready for whichever host owns the last meter. */
287
+ export interface ContinuationEvent {
288
+ context: string
289
+ answers: number
290
+ remaining: number
291
+ request_ids: string[]
292
+ journal_recorded_at: number
293
+ }
294
+
295
+ /**
296
+ * How much a route's own return proves about where the answer ended up.
297
+ *
298
+ * Acknowledgement belongs to the delivery, not to the harness envelope: routes
299
+ * end in different places, so no single field on a hook payload can speak for
300
+ * all of them. `stop_hook_active` is the harness confirming that a *blocking
301
+ * continuation* was admitted; it stays false for ever on a route that starts a
302
+ * brand-new turn instead of continuing this one, so a journal keyed to it alone
303
+ * never settles and every later turn-end redelivers the same answer.
304
+ *
305
+ * - `delivered` — the route completed a write to the harness itself (an inbox
306
+ * socket, a cold resume). Delivery is not consumption: the write proves the
307
+ * harness accepted the message, never that the model acted on it. But nothing
308
+ * later will ever prove more, and redelivering an answer without end is
309
+ * strictly worse than settling on the write, so the journal settles here.
310
+ * - `stdout` — the answer is this process's stdout, which the harness reads only
311
+ * after this process exits. Nothing this process can write is proof, so the
312
+ * journal waits for the successor Stop's `stop_hook_active`, and a crash
313
+ * before stdout replays the answer instead of losing it.
314
+ * - `held` — the route handed nothing over; the journal replays the answer.
315
+ */
316
+ export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
317
+
318
+ /** What a route returns: the hook's outcome plus what the attempt proved. */
319
+ export interface DeliveryOutcome {
320
+ stdout?: string
321
+ notes?: string[]
322
+ log?: Record<string, unknown>
323
+ acknowledgement: DeliveryAcknowledgement
324
+ }
325
+
326
+ /** Host adapter injected into the waiter; no route is implemented by the waiter. */
327
+ export interface EscalationDeliveryRoute {
328
+ kind: Exclude<DeliveryRoute, 'unsupported'>
329
+ deliver(event: ContinuationEvent): Promise<DeliveryOutcome>
330
+ }
331
+
332
+ export interface EscalationWaiterOptions {
333
+ sessionId: string
334
+ envelope: HookEnvelope
335
+ route: EscalationDeliveryRoute
336
+ processDeadlineAt?: number
337
+ }
338
+
280
339
  function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
281
340
  return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
282
341
  }
@@ -397,7 +456,7 @@ export function pruneAbandonedSessions(
397
456
  * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
398
457
  * no trustworthy PID fall back to an age limit.
399
458
  */
400
- const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
459
+ const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
401
460
  const heldClaims = new Map<string, string>()
402
461
 
403
462
  export function claimQuestionPush(
@@ -593,14 +652,26 @@ export function readProjectSession(
593
652
  return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
594
653
  }
595
654
 
596
- export function readProjectSessionPointer(
655
+ /** A live pointer keeps when it was last written, so callers can rank by it. */
656
+ export interface LiveProjectSessionPointer extends ProjectSessionPointer {
657
+ updatedAt: number
658
+ }
659
+
660
+ /**
661
+ * Every session that has fired a hook in this directory recently, newest first.
662
+ *
663
+ * This is the only local record of which harness sessions are actually alive
664
+ * here, which makes it the tiebreaker when the environment alone cannot say
665
+ * which harness owns the current shell.
666
+ */
667
+ export function readLiveProjectSessionPointers(
597
668
  cwd: string,
598
669
  env: NodeJS.ProcessEnv,
599
670
  now: number,
600
671
  maxAgeMs = 24 * 3600 * 1000,
601
- ): ProjectSessionPointer | null {
672
+ ): LiveProjectSessionPointer[] {
602
673
  const file = projectSessionPointerPath(cwd, env)
603
- const live = readStoredProjectSessionPointers(file)
674
+ return readStoredProjectSessionPointers(file)
604
675
  .filter((entry) => now - entry.updatedAt <= maxAgeMs)
605
676
  .filter((entry) => {
606
677
  try {
@@ -616,7 +687,20 @@ export function readProjectSessionPointer(
616
687
  }
617
688
  })
618
689
  .sort((left, right) => right.updatedAt - left.updatedAt)
619
- const latest = live[0]
690
+ .map((entry) => ({
691
+ sessionId: entry.sessionId,
692
+ harness: entry.harness,
693
+ updatedAt: entry.updatedAt,
694
+ }))
695
+ }
696
+
697
+ export function readProjectSessionPointer(
698
+ cwd: string,
699
+ env: NodeJS.ProcessEnv,
700
+ now: number,
701
+ maxAgeMs = 24 * 3600 * 1000,
702
+ ): ProjectSessionPointer | null {
703
+ const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0]
620
704
  return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
621
705
  }
622
706
 
@@ -628,26 +712,10 @@ export function readMatchingProjectSessionPointer(
628
712
  harness: HookHarness,
629
713
  maxAgeMs = 24 * 3600 * 1000,
630
714
  ): ProjectSessionPointer | null {
631
- const file = projectSessionPointerPath(cwd, env)
632
- const match = readStoredProjectSessionPointers(file).find(
633
- (entry) =>
634
- entry.sessionId === sessionId &&
635
- entry.harness === harness &&
636
- now - entry.updatedAt <= maxAgeMs,
715
+ const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find(
716
+ (entry) => entry.sessionId === sessionId && entry.harness === harness,
637
717
  )
638
- if (match === undefined) return null
639
- try {
640
- // A pointer is routing evidence only while its session state still exists
641
- // and parses. SessionEnd and explicit cleanup therefore invalidate it even
642
- // when a crash prevented the pointer file itself from being removed.
643
- const sessionFile = sessionStatePath(match.sessionId, env)
644
- if (!existsSync(sessionFile)) return null
645
- const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
646
- if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
647
- return { sessionId: match.sessionId, harness: match.harness }
648
- } catch {
649
- return null
650
- }
718
+ return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness }
651
719
  }
652
720
 
653
721
  function clearMatchingProjectSession(
@@ -677,66 +745,11 @@ function clearMatchingProjectSession(
677
745
  })
678
746
  }
679
747
 
680
- /**
681
- * Absent means the user is not at this machine's keyboard.
682
- *
683
- * OS idle time answers that directly, so where it exists it decides alone.
684
- * Silence since the user's last prompt is only ever a proxy for it, and a poor
685
- * one — it is wrong in both directions:
686
- *
687
- * - Too long: it counts the agent's own turn, so a user watching a build was
688
- * read as absent and had the question pushed at them.
689
- * - Too short: a session that has just been spawned always has a fresh
690
- * prompt, so its FIRST question could never escalate however long its
691
- * owner had been gone. That is the "kick off some agents and
692
- * walk away" case this feature mainly exists for, and requiring both
693
- * signals to agree is what broke it.
694
- *
695
- * The proxy therefore survives only as the fallback for machines with no idle
696
- * source. There, never having seen a prompt is not evidence of absence — it is
697
- * a missing `UserPromptSubmit` hook — so it resolves to "present".
698
- *
699
- * Answering from a companion device does not make the user present; only touching this
700
- * machine does. Answering on a device is evidence of being away from it.
701
- *
702
- * All of which only matters if presence is being consulted at all. With
703
- * `require_idle` off the user has said their whereabouts are not a
704
- * precondition, so this stops guessing and answers yes.
705
- */
706
- export function isUserAway(
707
- state: SessionState,
708
- config: CliConfig,
709
- now: number,
710
- idleSeconds: number | null,
711
- ): boolean {
712
- if (!config.require_idle.value) return true
713
- const threshold = config.away_after_seconds.value
714
- if (idleSeconds !== null) return idleSeconds >= threshold
715
- if (state.last_prompt_at === undefined) return false
716
- const silence = now - state.last_prompt_at
717
- // Both signs of a clock jump produce a wrong answer here, and the fallback
718
- // path has no monotonic reference to check against: a forward jump (NTP
719
- // correction, VM resume) hijacks a terminal whose user is sitting right
720
- // there, and a backward one suppresses escalation for someone genuinely
721
- // gone. Neither delta is evidence of anything, so it resolves the way every
722
- // other absence of evidence does — present.
723
- if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS) return false
724
- return silence >= threshold * 1000
725
- }
726
-
727
- /**
728
- * Beyond this, silence says more about the clock or an abandoned session file
729
- * than about the user. Matches the project pointer's staleness horizon.
730
- */
731
- const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000
732
-
733
748
  export interface HookContext {
734
749
  client: ApiClient
735
750
  config: CliConfig
736
751
  env: NodeJS.ProcessEnv
737
752
  now: () => number
738
- /** Seconds since the last keyboard/mouse event, or null if unknowable. */
739
- idleSeconds: () => number | null
740
753
  /** Injected so tests advance a virtual clock instead of sleeping. */
741
754
  sleep: (milliseconds: number) => Promise<void>
742
755
  /** Bounded wait for the first reply; injected so tests do not sleep. */
@@ -770,10 +783,9 @@ export type GateReason =
770
783
  | 'already-asked'
771
784
  | 'continuation-repeat'
772
785
  | 'continuation-limit'
786
+ | 'delivery-limit'
773
787
  | 'notifications-off'
774
- | 'user-present'
775
788
  | 'claimed-elsewhere'
776
- | 'user-returned'
777
789
  | 'no-devices'
778
790
  | 'proceeding'
779
791
 
@@ -783,13 +795,16 @@ function gate(
783
795
  reason: GateReason,
784
796
  data: Record<string, unknown> = {},
785
797
  ): void {
786
- ctx.log?.info('hook.gate', { verdict, reason, ...data })
798
+ ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
787
799
  }
788
800
 
789
801
  export type HookHarness = Harness
790
802
 
791
- /** Re-check local presence at this cadence while a pushed question is waiting. */
792
- const REPLY_PRESENCE_POLL_SECONDS = 5
803
+ /**
804
+ * One round of the waiter's reply poll. Short enough that several questions
805
+ * still share one wall clock, long enough not to hammer the server.
806
+ */
807
+ const REPLY_POLL_SECONDS = 5
793
808
 
794
809
  /**
795
810
  * Push a question and block for the answer.
@@ -875,20 +890,21 @@ async function submitQuestion(
875
890
  }
876
891
 
877
892
  /**
878
- * One blocking wait across every live question. Each round polls all of them
879
- * concurrently, so several questions cost the same wall clock as one; the
880
- * first round that finds any reply returns everything found in that round, and
881
- * whatever was not answered stays registered.
893
+ * The waiter's wait: one bounded poll across every live question at once.
894
+ *
895
+ * Each round polls all of them concurrently, so several questions cost the
896
+ * same wall clock as one; the first round that finds any reply returns
897
+ * everything found in that round, and whatever was not answered stays
898
+ * registered. A question whose polling is permanently rejected drops out of
899
+ * the round rather than failing the whole wait.
882
900
  */
883
- async function waitForAnyReplyWhileAway(
901
+ async function waitForAnyReply(
884
902
  ctx: HookContext,
885
903
  requestIds: string[],
886
904
  timeoutSeconds: number,
887
- monitorPresence = true,
888
905
  ): Promise<{
889
906
  byRequest: Map<string, ReplyView[]>
890
907
  degraded: boolean
891
- userReturned: boolean
892
908
  permanentFailures: Map<string, string>
893
909
  }> {
894
910
  const deadline = ctx.now() + timeoutSeconds * 1000
@@ -897,22 +913,12 @@ async function waitForAnyReplyWhileAway(
897
913
  const permanentFailures = new Map<string, string>()
898
914
 
899
915
  for (;;) {
900
- if (monitorPresence && ctx.config.require_idle.value) {
901
- const idle = ctx.idleSeconds()
902
- if (idle !== null && idle < ctx.config.away_after_seconds.value) {
903
- return { byRequest: new Map(), degraded, userReturned: true, permanentFailures }
904
- }
905
- }
906
-
907
916
  const remainingMs = Math.max(0, deadline - ctx.now())
908
917
  if (!firstPoll && remainingMs === 0) {
909
- return { byRequest: new Map(), degraded, userReturned: false, permanentFailures }
918
+ return { byRequest: new Map(), degraded, permanentFailures }
910
919
  }
911
920
  firstPoll = false
912
- const pollSeconds = Math.min(
913
- REPLY_PRESENCE_POLL_SECONDS,
914
- Math.max(0, Math.ceil(remainingMs / 1000)),
915
- )
921
+ const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
916
922
  const results = await Promise.all(
917
923
  requestIds
918
924
  .filter((requestId) => !permanentFailures.has(requestId))
@@ -941,7 +947,7 @@ async function waitForAnyReplyWhileAway(
941
947
  if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
942
948
  }
943
949
  if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
944
- return { byRequest, degraded, userReturned: false, permanentFailures }
950
+ return { byRequest, degraded, permanentFailures }
945
951
  }
946
952
  }
947
953
  }
@@ -1285,7 +1291,15 @@ export async function drainOrphanRetirements(
1285
1291
 
1286
1292
  const LATE_STOP_POLL_SECONDS = 4
1287
1293
  const LATE_PROMPT_POLL_SECONDS = 3
1288
- const MAX_CONTINUATION_COUNT = 3
1294
+ /**
1295
+ * The one bound on answer deliveries, for every route.
1296
+ *
1297
+ * It caps two things that both used to be able to run away: how many times a
1298
+ * single accepted answer may be handed to a route before the journal gives up
1299
+ * on it, and how many answer-driven continuation generations one chain may
1300
+ * produce.
1301
+ */
1302
+ export const MAX_CONTINUATION_COUNT = 3
1289
1303
 
1290
1304
  interface PendingPoll {
1291
1305
  /** The answer to act on: the latest reply, because a later one corrects. */
@@ -1361,6 +1375,7 @@ function reportAnswer(
1361
1375
  ): void {
1362
1376
  ctx.log?.info('hook.answer', {
1363
1377
  answered: true,
1378
+ stage: 'queued',
1364
1379
  ...(late ? { late: true } : {}),
1365
1380
  request_id: answered.pending.request_id,
1366
1381
  device: answered.reply.device_name,
@@ -1429,18 +1444,28 @@ function pendingHasChoices(pending: PendingQuestion): boolean {
1429
1444
  * it. Free-text answers can arrive in parts, and every part reaches the
1430
1445
  * agent in the order it was written, so it can tell expansion from
1431
1446
  * correction itself.
1447
+ *
1448
+ * The continuation repeats only identity the agent actually registered and
1449
+ * the answer the server accepted. It never invents trust, urgency, permission,
1450
+ * or approval claims for the transport to assert.
1432
1451
  */
1433
- function answerContext(replies: ReplyView[], hadChoices: boolean): string {
1452
+ function answerContext(answered: AnsweredPending): string {
1453
+ const { pending, replies } = answered
1434
1454
  const latest = replies.at(-1)
1435
1455
  if (latest === undefined) return 'Notifai — no answer was recorded.'
1436
- if (replies.length === 1 || hadChoices) {
1437
- return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
1456
+ const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))]
1457
+ const identity =
1458
+ answeredQuestionIds.length === 0
1459
+ ? ''
1460
+ : `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `
1461
+ const question = `question ${JSON.stringify(pending.question)}`
1462
+ if (replies.length === 1 || pendingHasChoices(pending)) {
1463
+ return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`
1438
1464
  }
1439
- const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
1465
+ const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ')
1440
1466
  return (
1441
- `Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
1442
- `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
1443
- 'Continue with that answer.'
1467
+ `Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
1468
+ `in the order written: ${parts}. Later parts extend or correct earlier ones.`
1444
1469
  )
1445
1470
  }
1446
1471
 
@@ -1456,18 +1481,19 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
1456
1481
  ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1457
1482
  : ''
1458
1483
  if (answered.length === 1) {
1459
- const only = answered[0]!
1460
- return answerContext(only.replies, pendingHasChoices(only.pending)) + tail
1484
+ return answerContext(answered[0]!) + tail
1461
1485
  }
1462
1486
  const lines = answered.map(({ pending, replies }) => {
1463
1487
  const latest = replies.at(-1)!
1464
1488
  const answer =
1465
1489
  replies.length === 1 || pendingHasChoices(pending)
1466
- ? `"${latest.text}"`
1467
- : `${replies.map((reply) => `"${reply.text}"`).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
1468
- return `- "${pending.question}" → ${answer} (from ${latest.device_name})`
1490
+ ? JSON.stringify(latest.text)
1491
+ : `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
1492
+ const ids = [...new Set(latest.answers.map((entry) => entry.question_id))]
1493
+ const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
1494
+ return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
1469
1495
  })
1470
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`
1496
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`
1471
1497
  }
1472
1498
 
1473
1499
  function stopAnswerOutput(context: string): string {
@@ -1488,26 +1514,46 @@ function stageAcceptedAnswers(
1488
1514
  sessionId: string,
1489
1515
  answered: AnsweredPending[],
1490
1516
  remaining: number,
1491
- ): void {
1517
+ ): AcceptedAnswerDelivery {
1518
+ const accepted: AcceptedAnswerDelivery = {
1519
+ answers: answered,
1520
+ remaining,
1521
+ recorded_at: ctx.now(),
1522
+ }
1492
1523
  updateSessionState(sessionId, ctx.env, (current) => {
1493
1524
  const pendingRemaining = pendingList(current).filter(
1494
1525
  (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1495
1526
  )
1496
1527
  const next: SessionState = {
1497
1528
  ...current,
1498
- accepted: {
1499
- answers: answered,
1500
- remaining,
1501
- recorded_at: ctx.now(),
1502
- },
1529
+ accepted,
1503
1530
  }
1504
1531
  if (pendingRemaining.length > 0) next.pending = pendingRemaining
1505
1532
  else delete next.pending
1506
1533
  return next
1507
1534
  })
1535
+ return accepted
1508
1536
  }
1509
1537
 
1510
- /** Settle only after a successor hook proves the harness received the answer. */
1538
+ /** Amend the live accepted record in place, without resurrecting a settled one. */
1539
+ function amendAcceptedAnswers(
1540
+ ctx: HookContext,
1541
+ sessionId: string,
1542
+ amend: (accepted: AcceptedAnswerDelivery) => AcceptedAnswerDelivery,
1543
+ ): void {
1544
+ updateSessionState(sessionId, ctx.env, (current) =>
1545
+ current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) },
1546
+ )
1547
+ }
1548
+
1549
+ /**
1550
+ * Retire the answered questions and close the journal.
1551
+ *
1552
+ * Called once an answer's delivery is acknowledged — by the route's own write,
1553
+ * or by the successor Stop of a blocking continuation. The continuation counter
1554
+ * grows with each settled generation, so the cap that bounds chained
1555
+ * answer-to-question loops is a real count rather than a constant 1.
1556
+ */
1511
1557
  function settleAcceptedAnswers(
1512
1558
  ctx: HookContext,
1513
1559
  sessionId: string,
@@ -1527,7 +1573,7 @@ function settleAcceptedAnswers(
1527
1573
  delete next.accepted
1528
1574
  next.continuation = {
1529
1575
  answered_at: accepted.recorded_at,
1530
- count: accepted.continuation_count ?? 1,
1576
+ count: (current.continuation?.count ?? 0) + 1,
1531
1577
  }
1532
1578
  return next
1533
1579
  })
@@ -1590,7 +1636,11 @@ export async function handleUserPromptSubmit(
1590
1636
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1591
1637
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
1592
1638
  }
1593
- notes.push('a device answer is safely journaled; the next Stop will deliver it')
1639
+ notes.push(
1640
+ state.accepted.delivered_at === undefined
1641
+ ? 'a device answer is safely journaled; the next Stop will deliver it'
1642
+ : 'a device answer has already been delivered; the next Stop will close it out',
1643
+ )
1594
1644
  return { notes }
1595
1645
  }
1596
1646
  const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
@@ -1653,7 +1703,14 @@ export async function handleUserPromptSubmit(
1653
1703
  ...(unasked.length > 0 ? { pending: unasked } : {}),
1654
1704
  ...(retiring.length > 0 ? { retiring } : {}),
1655
1705
  ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1656
- ...(current.continuation === undefined ? {} : { continuation: current.continuation }),
1706
+ // The user typed, so whatever chain of answer-driven continuations was
1707
+ // running, a human has taken the turn and the consecutive count starts
1708
+ // over. Only a real prompt reaches here: the journal branch above returns
1709
+ // first, and a wake route's injected message always arrives while its own
1710
+ // answer is still journaled, so it can never pass for the user's presence.
1711
+ ...(current.continuation === undefined
1712
+ ? {}
1713
+ : { continuation: { ...current.continuation, count: 0 } }),
1657
1714
  }
1658
1715
  })
1659
1716
  // The bridge that lets a plain `notifai ask` find the hook's canonical
@@ -1691,15 +1748,34 @@ export async function handleUserPromptSubmit(
1691
1748
  export async function handleStop(
1692
1749
  ctx: HookContext,
1693
1750
  envelope: HookEnvelope,
1694
- processDeadlineAt = ctx.now() + STOP_BUDGET_SECONDS * 1000,
1751
+ processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000,
1752
+ route: EscalationDeliveryRoute = hookContinuationRoute(),
1695
1753
  ): Promise<HookOutcome> {
1696
- const notes: string[] = []
1697
- const hardDeadlineAt = processDeadlineAt
1698
1754
  const sessionId = envelope.session_id
1699
1755
  if (!sessionId) {
1700
- gate(ctx, 'held', 'no-session')
1701
- return { notes }
1756
+ gate(ctx, 'held', 'no-session', { stage: 'queued' })
1757
+ return { notes: [] }
1702
1758
  }
1759
+ return runEscalationWaiter(ctx, {
1760
+ sessionId,
1761
+ envelope,
1762
+ route,
1763
+ processDeadlineAt,
1764
+ })
1765
+ }
1766
+
1767
+ /**
1768
+ * Own one registered question pipeline from its terminal-first timer through
1769
+ * its final delivery or retirement. Hosts differ only in the injected route.
1770
+ */
1771
+ export async function runEscalationWaiter(
1772
+ ctx: HookContext,
1773
+ options: EscalationWaiterOptions,
1774
+ ): Promise<HookOutcome> {
1775
+ const notes: string[] = []
1776
+ const { sessionId, envelope } = options
1777
+ const hardDeadlineAt =
1778
+ options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000
1703
1779
 
1704
1780
  // This event marker is diagnostic evidence in its own right. Record it
1705
1781
  // before every early return so doctor can distinguish a working Stop route
@@ -1709,47 +1785,39 @@ export async function handleStop(
1709
1785
  last_stop_at: ctx.now(),
1710
1786
  }))
1711
1787
 
1712
- // Claim before any reply poll, not only before the grace window. Two racing
1713
- // Stops can both observe the same accepted journal or live request. An
1714
- // atomic state update cannot retract duplicate block output a loser already
1715
- // built, so one session claim owns the complete Stop decision: accepted
1716
- // replay/ack, late-answer collection, cleanup, and any new escalation.
1717
- //
1718
- // Real clock, deliberately, not `ctx.now` — the claim answers "is another
1719
- // process alive right now", which the injectable clock cannot speak to. It
1720
- // is also the only clock the *other* process shares.
1788
+ // The waiter, not its host, owns this lease. It spans grace, submission,
1789
+ // polling, close fencing, route delivery, and every retirement path.
1790
+ // Real clock, deliberately, not `ctx.now` — the claim answers whether
1791
+ // another process is alive right now, which an injected clock cannot know.
1721
1792
  if (!claimQuestionPush(sessionId, ctx.env)) {
1722
- gate(ctx, 'held', 'claimed-elsewhere')
1793
+ gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
1723
1794
  notes.push('another hook is already handling this question')
1724
1795
  return { notes }
1725
1796
  }
1797
+ let settledAnswerThisPass = false
1726
1798
  try {
1727
1799
  let state = readSessionState(sessionId, ctx.env)
1728
1800
  if (state.accepted !== undefined) {
1729
1801
  const accepted = state.accepted
1730
- // A recursive Stop is emitted only after the previous Stop's blocking
1731
- // output was admitted and the continued model turn ran. That event is
1732
- // the acknowledgment: persisting a separate "offered" bit before stdout
1733
- // would recreate the crash gap this journal exists to close.
1734
- const deliveryProven = envelope.stop_hook_active === true
1802
+ // Acknowledgement is per route, because routes end in different places.
1803
+ //
1804
+ // A blocking continuation ends at this process's stdout, which the
1805
+ // harness reads only after the process exits: the recursive Stop's
1806
+ // `stop_hook_active` is the acknowledgement, and persisting a separate
1807
+ // "offered" bit before stdout would recreate the crash gap this journal
1808
+ // exists to close. An out-of-band route ends at a write this process made
1809
+ // itself and recorded here. A socket write is not proof the model acted
1810
+ // on the answer — delivery is not consumption — but nothing later can
1811
+ // prove more, and an answer redelivered without end is strictly worse
1812
+ // than one settled on delivery. So a recorded delivery settles.
1813
+ const deliveryProven =
1814
+ accepted.delivered_at !== undefined || envelope.stop_hook_active === true
1735
1815
  if (!deliveryProven) {
1736
- updateSessionState(sessionId, ctx.env, (current) => {
1737
- if (current.accepted === undefined) return current
1738
- return {
1739
- ...current,
1740
- accepted: {
1741
- ...current.accepted,
1742
- continuation_count: (current.continuation?.count ?? 0) + 1,
1743
- },
1744
- }
1745
- })
1746
1816
  for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
1747
- return {
1748
- stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
1749
- notes,
1750
- }
1817
+ return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes)
1751
1818
  }
1752
1819
  settleAcceptedAnswers(ctx, sessionId, accepted)
1820
+ settledAnswerThisPass = true
1753
1821
  state = readSessionState(sessionId, ctx.env)
1754
1822
  }
1755
1823
 
@@ -1762,7 +1830,7 @@ export async function handleStop(
1762
1830
  if (swept.length > 0) {
1763
1831
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
1764
1832
  }
1765
- gate(ctx, 'held', 'no-question')
1833
+ gate(ctx, 'held', 'no-question', { stage: 'queued' })
1766
1834
  return { notes }
1767
1835
  }
1768
1836
 
@@ -1774,12 +1842,120 @@ export async function handleStop(
1774
1842
  pending,
1775
1843
  notes,
1776
1844
  hardDeadlineAt,
1845
+ options.route,
1846
+ // Every route's successor turn-end, named the same way: the harness flag
1847
+ // for a blocking continuation, and the settle above for a route that woke
1848
+ // a brand-new turn out of band.
1849
+ envelope.stop_hook_active === true || settledAnswerThisPass,
1777
1850
  )
1778
1851
  } finally {
1779
1852
  releaseQuestionPush(sessionId, ctx.env)
1780
1853
  }
1781
1854
  }
1782
1855
 
1856
+ /**
1857
+ * The blocking Stop hook's own last meter: the answer becomes this process's
1858
+ * stdout, and the harness admits it as a new user turn. Exported because a
1859
+ * host adapter that has other routes still falls back to this one.
1860
+ */
1861
+ export function hookContinuationRoute(): EscalationDeliveryRoute {
1862
+ return {
1863
+ kind: 'hook-continuation',
1864
+ deliver: async (event) => ({
1865
+ stdout: stopAnswerOutput(event.context),
1866
+ notes: [],
1867
+ // The harness reads this stdout after the process exits, so only the
1868
+ // successor Stop can acknowledge it.
1869
+ acknowledgement: 'stdout',
1870
+ }),
1871
+ }
1872
+ }
1873
+
1874
+ /**
1875
+ * Hand one accepted answer to a route and record what the attempt proved.
1876
+ *
1877
+ * Every delivery on every route passes through here, which is what makes the
1878
+ * cap below a backstop rather than a suggestion. A guard only one route
1879
+ * consults is not a backstop: that is how a single answer once reached a
1880
+ * session 250 times.
1881
+ */
1882
+ async function deliverAcceptedAnswers(
1883
+ ctx: HookContext,
1884
+ sessionId: string,
1885
+ route: EscalationDeliveryRoute,
1886
+ accepted: AcceptedAnswerDelivery,
1887
+ notes: string[],
1888
+ ): Promise<HookOutcome> {
1889
+ const { answers: answered, remaining } = accepted
1890
+ const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
1891
+ const attempt = (accepted.delivery_attempts ?? 0) + 1
1892
+ if (attempt > MAX_CONTINUATION_COUNT) {
1893
+ gate(ctx, 'held', 'delivery-limit', {
1894
+ route: route.kind,
1895
+ attempts: accepted.delivery_attempts ?? 0,
1896
+ limit: MAX_CONTINUATION_COUNT,
1897
+ request_ids: requestIds,
1898
+ })
1899
+ // Settle rather than journal for ever. The answer has been offered as often
1900
+ // as any working route could need, it is already in the notes of each of
1901
+ // those turns, and the questions it answered still deserve retirement.
1902
+ settleAcceptedAnswers(ctx, sessionId, accepted)
1903
+ notes.push(
1904
+ `the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
1905
+ )
1906
+ return { notes }
1907
+ }
1908
+ // Count the attempt before making it: an attempt that dies mid-delivery still
1909
+ // happened, and a counter written afterwards could be evaded by crashing.
1910
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1911
+ ...current,
1912
+ delivery_attempts: attempt,
1913
+ }))
1914
+ ctx.log?.info('hook.gate', {
1915
+ verdict: 'proceeding',
1916
+ reason: 'answered',
1917
+ stage: 'routed',
1918
+ route: route.kind,
1919
+ answers: answered.length,
1920
+ request_ids: requestIds,
1921
+ journal_recorded_at: accepted.recorded_at,
1922
+ delivery_attempt: attempt,
1923
+ })
1924
+ const delivered = await route.deliver({
1925
+ context: answersContext(answered, remaining),
1926
+ answers: answered.length,
1927
+ remaining,
1928
+ request_ids: requestIds,
1929
+ journal_recorded_at: accepted.recorded_at,
1930
+ })
1931
+ const deliveredRoute =
1932
+ typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
1933
+ const deliveredStage =
1934
+ typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
1935
+ if (delivered.acknowledgement === 'delivered') {
1936
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1937
+ ...current,
1938
+ delivered_at: ctx.now(),
1939
+ delivered_route: deliveredRoute,
1940
+ }))
1941
+ }
1942
+ ctx.log?.info('hook.answer', {
1943
+ answered: true,
1944
+ stage: deliveredStage,
1945
+ route: deliveredRoute,
1946
+ acknowledgement: delivered.acknowledgement,
1947
+ delivery_attempt: attempt,
1948
+ request_ids: requestIds,
1949
+ answers: answered.length,
1950
+ journal_recorded_at: accepted.recorded_at,
1951
+ ...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
1952
+ })
1953
+ const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
1954
+ if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
1955
+ if (delivered.log !== undefined) outcome.log = delivered.log
1956
+ return outcome
1957
+ }
1958
+
1783
1959
  /** The complete per-session Stop decision, after this process owns the claim. */
1784
1960
  async function handleClaimedStop(
1785
1961
  ctx: HookContext,
@@ -1789,6 +1965,8 @@ async function handleClaimedStop(
1789
1965
  pending: PendingQuestion[],
1790
1966
  notes: string[],
1791
1967
  hardDeadlineAt: number,
1968
+ route: EscalationDeliveryRoute,
1969
+ continuingFromAnswer: boolean,
1792
1970
  ): Promise<HookOutcome> {
1793
1971
  const live = pending.filter((entry) => entry.request_id !== undefined)
1794
1972
  const unasked = pending.filter((entry) => entry.request_id === undefined)
@@ -1837,25 +2015,16 @@ async function handleClaimedStop(
1837
2015
  }
1838
2016
  return { ...entry, replies, reply: replies.at(-1)! }
1839
2017
  })
1840
- stageAcceptedAnswers(
2018
+ const accepted = stageAcceptedAnswers(
1841
2019
  ctx,
1842
2020
  sessionId,
1843
2021
  authoritative,
1844
2022
  pending.length - authoritative.length - permanentlyRejected.length,
1845
2023
  )
1846
- gate(ctx, 'proceeding', 'answered', { answers: authoritative.length })
1847
2024
  for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
1848
2025
  // Anything still unasked rides the next Stop: the agent is being resumed
1849
2026
  // with answers right now, and may not even need the rest afterwards.
1850
- return {
1851
- stdout: stopAnswerOutput(
1852
- answersContext(
1853
- authoritative,
1854
- pending.length - authoritative.length - permanentlyRejected.length,
1855
- ),
1856
- ),
1857
- notes,
1858
- }
2027
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
1859
2028
  }
1860
2029
  const recoverableLive = live.filter(
1861
2030
  (entry) => !permanentFailures.has(entry.request_id!),
@@ -1875,6 +2044,7 @@ async function handleClaimedStop(
1875
2044
  recoverableLive,
1876
2045
  notes,
1877
2046
  hardDeadlineAt,
2047
+ route,
1878
2048
  )
1879
2049
  }
1880
2050
  // Questions registered after the earlier push still owe the user their
@@ -1896,16 +2066,26 @@ async function handleClaimedStop(
1896
2066
  notes.push('already continuing from an answer; not asking again this turn')
1897
2067
  return { notes }
1898
2068
  }
1899
- if (continuation.count >= MAX_CONTINUATION_COUNT) {
1900
- gate(ctx, 'held', 'continuation-limit', {
1901
- count: continuation.count,
1902
- limit: MAX_CONTINUATION_COUNT,
1903
- })
1904
- notes.push(
1905
- `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
1906
- )
1907
- return { notes }
1908
- }
2069
+ }
2070
+ // The cap is deliberately not keyed to `stop_hook_active`. That flag names one
2071
+ // route's continuation; a route that wakes a brand-new turn never sets it, and
2072
+ // a limit only one route consults bounds only that route. `continuingFromAnswer`
2073
+ // is the same event named for every route, so the chain of answer → follow-up
2074
+ // question → answer is bounded whatever delivered it.
2075
+ if (
2076
+ continuingFromAnswer &&
2077
+ state.continuation !== undefined &&
2078
+ state.continuation.count >= MAX_CONTINUATION_COUNT
2079
+ ) {
2080
+ gate(ctx, 'held', 'continuation-limit', {
2081
+ count: state.continuation.count,
2082
+ limit: MAX_CONTINUATION_COUNT,
2083
+ route: route.kind,
2084
+ })
2085
+ notes.push(
2086
+ `answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
2087
+ )
2088
+ return { notes }
1909
2089
  }
1910
2090
  // Silent to the user by design — they switched routing off, so saying so on
1911
2091
  // every turn would be nagging about their own setting. That silence is also
@@ -1915,21 +2095,9 @@ async function handleClaimedStop(
1915
2095
  gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
1916
2096
  return { notes }
1917
2097
  }
1918
- const idle = ctx.idleSeconds()
1919
- if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
1920
- gate(ctx, 'held', 'user-present', {
1921
- idle_seconds: idle,
1922
- away_after_seconds: ctx.config.away_after_seconds.value,
1923
- require_idle: ctx.config.require_idle.value,
1924
- last_prompt_at: state.last_prompt_at ?? null,
1925
- })
1926
- notes.push('you are at the keyboard; leaving the question in the terminal')
1927
- return { notes }
1928
- }
1929
2098
  gate(ctx, 'proceeding', 'proceeding', {
1930
2099
  unasked: unasked.length,
1931
2100
  already_live: liveToEscalate.length,
1932
- idle_seconds: idle,
1933
2101
  grace_seconds: ctx.config.ask_grace_seconds.value,
1934
2102
  })
1935
2103
  return await escalate(
@@ -1940,6 +2108,7 @@ async function handleClaimedStop(
1940
2108
  liveToEscalate,
1941
2109
  notes,
1942
2110
  hardDeadlineAt,
2111
+ route,
1943
2112
  )
1944
2113
  }
1945
2114
 
@@ -1952,56 +2121,46 @@ async function escalate(
1952
2121
  alreadyLive: PendingQuestion[],
1953
2122
  notes: string[],
1954
2123
  hardDeadlineAt: number,
2124
+ route: EscalationDeliveryRoute,
1955
2125
  ): Promise<HookOutcome> {
1956
- // Away right now, but the questions still owe the user their terminal-first
1957
- // window before anything reaches their devices — measured from the oldest
1958
- // registration, because that is the question that has waited longest.
1959
- const budgetStartedAt = ctx.now()
2126
+ // The questions still owe the user their terminal-first window before
2127
+ // anything reaches their devices — measured from the oldest registration,
2128
+ // because that is the question that has waited longest.
2129
+ const startedAt = ctx.now()
1960
2130
  const existingDeadlines = alreadyLive.flatMap((entry) =>
1961
- entry.reply_deadline_at === undefined || entry.reply_deadline_at <= budgetStartedAt
2131
+ entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
1962
2132
  ? []
1963
2133
  : [entry.reply_deadline_at],
1964
2134
  )
1965
- const budgetDeadlineAt = Math.min(
1966
- hardDeadlineAt - HOOK_TIMING.finalizationReserveSeconds * 1000,
2135
+ const ceilingAt = Math.min(
2136
+ hardDeadlineAt,
1967
2137
  ...(existingDeadlines.length === 0 ? [] : existingDeadlines),
1968
2138
  )
1969
2139
  if (unasked.length > 0 && alreadyLive.length === 0) {
1970
2140
  const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
1971
- const grace = await awaitGrace(ctx, oldest)
1972
- ctx.log?.debug('hook.gate', { verdict: 'grace', reason: grace, waited_from: oldest })
1973
- if (grace === 'user-returned') {
1974
- gate(ctx, 'held', 'user-returned', { grace_seconds: ctx.config.ask_grace_seconds.value })
1975
- notes.push('you came back before the wait elapsed; leaving the questions in the terminal')
1976
- return { notes }
1977
- }
1978
- if (grace === 'no-signal') {
1979
- notes.push('no idle signal on this machine; asking now rather than holding the terminal')
1980
- }
2141
+ await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
2142
+ ctx.log?.debug('hook.gate', {
2143
+ verdict: 'grace',
2144
+ reason: 'elapsed',
2145
+ waited_from: oldest,
2146
+ grace_seconds: ctx.config.ask_grace_seconds.value,
2147
+ })
1981
2148
  }
1982
2149
  // Phase one: every registered question reaches the user's devices, each as
1983
2150
  // its own notification — one ask never stands in for another.
1984
2151
  const submitted: PendingQuestion[] = []
1985
2152
  for (const entry of unasked) {
1986
- const remainingSeconds = Math.floor((budgetDeadlineAt - ctx.now()) / 1000)
1987
- if (
1988
- remainingSeconds <
1989
- MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds
1990
- ) {
2153
+ // The reply window is the question's own: whatever is left of the waiter's
2154
+ // ceiling, granted to the server so it stops accepting answers at the same
2155
+ // moment this owner stops listening for them.
2156
+ const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000)
2157
+ if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
1991
2158
  notes.push(
1992
- 'the Stop budget cannot fit device resolution, submission, and a full reply window; leaving this question in the terminal',
2159
+ 'too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal',
1993
2160
  )
1994
2161
  continue
1995
2162
  }
1996
- const replyWindowSeconds = Math.min(
1997
- ctx.config.hook_reply_timeout_seconds.value,
1998
- remainingSeconds - HOOK_TIMING.submissionReserveSeconds,
1999
- )
2000
- const ownerDeadlineAt = Math.min(
2001
- budgetDeadlineAt,
2002
- ctx.now() +
2003
- (replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000,
2004
- )
2163
+ const ownerDeadlineAt = ceilingAt
2005
2164
  const questions = pendingQuestions(entry)
2006
2165
  let intent = entry.submission
2007
2166
  if (intent === undefined) {
@@ -2044,16 +2203,7 @@ async function escalate(
2044
2203
  // The frozen wire identity survives a crashed owner, but its local owner
2045
2204
  // lease does not. Re-arm only the local deadline; request id, key,
2046
2205
  // targets, and draft remain byte-identical for idempotent replay.
2047
- const frozenReplyWindow =
2048
- intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds
2049
- intent = {
2050
- ...intent,
2051
- owner_deadline_at: Math.min(
2052
- budgetDeadlineAt,
2053
- ctx.now() +
2054
- (frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000,
2055
- ),
2056
- }
2206
+ intent = { ...intent, owner_deadline_at: ceilingAt }
2057
2207
  const rearmed = intent
2058
2208
  updateSessionState(sessionId, ctx.env, (current) => {
2059
2209
  const list = pendingList(current)
@@ -2065,16 +2215,13 @@ async function escalate(
2065
2215
  })
2066
2216
  }
2067
2217
  if (ctx.now() >= intent.owner_deadline_at) {
2068
- notes.push('the Stop budget ended before submission; preserving the frozen intent')
2218
+ notes.push('the waiter ceiling ended before submission; preserving the frozen intent')
2069
2219
  continue
2070
2220
  }
2071
2221
  const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
2072
- if (
2073
- beforeSubmitSeconds <
2074
- MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2
2075
- ) {
2222
+ if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
2076
2223
  notes.push(
2077
- 'device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window',
2224
+ 'device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts',
2078
2225
  )
2079
2226
  continue
2080
2227
  }
@@ -2195,12 +2342,9 @@ async function escalate(
2195
2342
  0,
2196
2343
  pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
2197
2344
  )
2198
- stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
2345
+ const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
2199
2346
  for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
2200
- return {
2201
- stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
2202
- notes,
2203
- }
2347
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2204
2348
  }
2205
2349
  const waitingOn = [
2206
2350
  ...alreadyLive.filter(
@@ -2210,17 +2354,16 @@ async function escalate(
2210
2354
  ]
2211
2355
  if (waitingOn.length === 0) return { notes }
2212
2356
 
2213
- // Phase two: one blocking wait across everything live, old and new alike.
2357
+ // Phase two: one wait across everything live, old and new alike.
2214
2358
  const ownerDeadline = Math.min(
2215
- budgetDeadlineAt,
2359
+ ceilingAt,
2216
2360
  Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
2217
2361
  )
2218
2362
  const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
2219
- const waited = await waitForAnyReplyWhileAway(
2363
+ const waited = await waitForAnyReply(
2220
2364
  ctx,
2221
2365
  waitingOn.map((entry) => entry.request_id!),
2222
2366
  timeoutSeconds,
2223
- ctx.harness !== 'opencode',
2224
2367
  )
2225
2368
  const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
2226
2369
  if (permanentFailure !== null) notes.push(permanentFailure)
@@ -2238,13 +2381,7 @@ async function escalate(
2238
2381
  .map((entry) => entry.pending)
2239
2382
  const unproven = finalized.filter((entry) => entry.response === null)
2240
2383
  if (confirmedSilent.length > 0) {
2241
- await retirePendings(
2242
- ctx,
2243
- envelope,
2244
- sessionId,
2245
- confirmedSilent,
2246
- waited.userReturned ? 'answered_elsewhere' : 'expired',
2247
- )
2384
+ await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired')
2248
2385
  }
2249
2386
  if (unproven.length > 0) {
2250
2387
  notes.push(
@@ -2256,27 +2393,20 @@ async function escalate(
2256
2393
  0,
2257
2394
  pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
2258
2395
  )
2259
- stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
2396
+ const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
2260
2397
  for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
2261
- return {
2262
- stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
2263
- notes,
2264
- }
2398
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2265
2399
  }
2266
2400
  const requestIdSummary = summarizeRequestIds(waitingOn)
2267
2401
  ctx.log?.info('hook.answer', {
2268
2402
  answered: false,
2403
+ stage: 'queued',
2269
2404
  request_ids: requestIdSummary.ids,
2270
- user_returned: waited.userReturned,
2271
2405
  degraded: waited.degraded,
2272
2406
  permanent_failures: waited.permanentFailures.size,
2273
2407
  waited_seconds: timeoutSeconds,
2274
2408
  })
2275
- if (waited.userReturned) {
2276
- notes.push(
2277
- `you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; ${waitingOn.length === 1 ? 'it was' : 'they were'} retired before returning the terminal (${requestIdSummary.display})`,
2278
- )
2279
- } else if (waited.permanentFailures.size === 0) {
2409
+ if (waited.permanentFailures.size === 0) {
2280
2410
  notes.push(
2281
2411
  waited.degraded
2282
2412
  ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
@@ -2305,17 +2435,14 @@ async function escalate(
2305
2435
  )
2306
2436
  }
2307
2437
  }
2308
- stageAcceptedAnswers(
2438
+ const accepted = stageAcceptedAnswers(
2309
2439
  ctx,
2310
2440
  sessionId,
2311
2441
  answered,
2312
2442
  waitingOn.length - answered.length,
2313
2443
  )
2314
2444
  for (const answer of answered) reportAnswer(ctx, notes, answer, false)
2315
- return {
2316
- stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
2317
- notes,
2318
- }
2445
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2319
2446
  }
2320
2447
 
2321
2448
  // ---------------------------------------------------------------------------