@raidiant/notifai 0.5.0 → 1.0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/claude-wake.d.ts +101 -0
  4. package/dist/claude-wake.js +397 -0
  5. package/dist/claude-wake.js.map +1 -0
  6. package/dist/codex-wake.d.ts +84 -0
  7. package/dist/codex-wake.js +238 -0
  8. package/dist/codex-wake.js.map +1 -0
  9. package/dist/commands.d.ts +31 -11
  10. package/dist/commands.js +679 -232
  11. package/dist/commands.js.map +1 -1
  12. package/dist/config-schema.d.ts +3 -3
  13. package/dist/config-schema.js +11 -49
  14. package/dist/config-schema.js.map +1 -1
  15. package/dist/config.d.ts +23 -45
  16. package/dist/config.js +61 -39
  17. package/dist/config.js.map +1 -1
  18. package/dist/harnesses.d.ts +4 -13
  19. package/dist/harnesses.js +12 -17
  20. package/dist/harnesses.js.map +1 -1
  21. package/dist/hook-adapter.d.ts +10 -1
  22. package/dist/hook-adapter.js +79 -10
  23. package/dist/hook-adapter.js.map +1 -1
  24. package/dist/hooks.d.ts +119 -65
  25. package/dist/hooks.js +330 -285
  26. package/dist/hooks.js.map +1 -1
  27. package/dist/install-hooks.d.ts +76 -24
  28. package/dist/install-hooks.js +301 -125
  29. package/dist/install-hooks.js.map +1 -1
  30. package/dist/interactive.d.ts +32 -1
  31. package/dist/interactive.js +165 -51
  32. package/dist/interactive.js.map +1 -1
  33. package/dist/main.js +14 -21
  34. package/dist/main.js.map +1 -1
  35. package/dist/native-skills.d.ts +1 -1
  36. package/dist/native-skills.js +23 -58
  37. package/dist/native-skills.js.map +1 -1
  38. package/dist/pack-feedback-slice.d.ts +35 -0
  39. package/dist/pack-feedback-slice.js +126 -0
  40. package/dist/pack-feedback-slice.js.map +1 -0
  41. package/dist/readiness.d.ts +20 -0
  42. package/dist/readiness.js +24 -0
  43. package/dist/readiness.js.map +1 -1
  44. package/dist/release.d.ts +31 -0
  45. package/dist/release.js +52 -0
  46. package/dist/release.js.map +1 -0
  47. package/dist/ui/theme.d.ts +1 -1
  48. package/package.json +11 -10
  49. package/src/commands.test.ts +386 -36
  50. package/src/commands.ts +473 -182
  51. package/src/config-schema.ts +2 -17
  52. package/src/config.test.ts +29 -4
  53. package/src/config.ts +67 -11
  54. package/src/harnesses.ts +7 -0
  55. package/src/hook-adapter.test.ts +43 -1
  56. package/src/hook-adapter.ts +83 -11
  57. package/src/hooks.test.ts +4 -5
  58. package/src/install-hooks.test.ts +154 -2
  59. package/src/install-hooks.ts +231 -44
  60. package/src/interactive.test.ts +338 -0
  61. package/src/interactive.ts +221 -49
  62. package/src/main.ts +14 -20
  63. package/src/native-skills.test.ts +75 -0
  64. package/src/native-skills.ts +31 -75
  65. package/src/pack-feedback-slice.test.ts +212 -0
  66. package/src/pack-feedback-slice.ts +187 -0
  67. package/src/readiness.test.ts +25 -0
  68. package/src/readiness.ts +38 -0
  69. package/src/release.ts +51 -0
  70. package/src/skill.test.ts +43 -0
  71. package/src/ui/config-view.test.ts +5 -4
  72. package/dist/idle.d.ts +0 -16
  73. package/dist/idle.js +0 -44
  74. package/dist/idle.js.map +0 -1
package/dist/hooks.js CHANGED
@@ -6,7 +6,7 @@ import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfig
6
6
  import { buildDraft } from './send.js';
7
7
  import { atomicWriteFileSync } from './atomic-file.js';
8
8
  import { withFileLock } from './file-lock.js';
9
- import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING } from './harnesses.js';
9
+ import { HARNESS_CAPABILITIES, HARNESSES, } from './harnesses.js';
10
10
  /**
11
11
  * Past this, the question's reply window (3600s) has long expired server-side
12
12
  * and the companion shows it as dead on next open anyway; pushing a retirement
@@ -20,58 +20,46 @@ function summarizeRequestIds(entries) {
20
20
  const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
21
21
  return { ids, display: ids.join(', ') };
22
22
  }
23
- /** How often the grace window rechecks whether the user has come back. */
24
- const GRACE_POLL_MS = 5_000;
25
23
  /**
26
- * Total seconds the complete Stop invocation may own. Generated adapters grant
27
- * another minute of host headroom, because a killed hook can lose an answer
28
- * the user has already given. Grace and reply waiting share this budget while
29
- * the reply retains a small actionable window and finalization reserve.
24
+ * The waiter's whole wall clock: the terminal-first timer, submission, and the
25
+ * wait for an answer, from the moment the turn-end hook starts.
26
+ *
27
+ * One plain ceiling, deliberately not a budget carved into reserves. The
28
+ * reserves existed to fit every phase inside a *held* turn; on Claude Code the
29
+ * turn is not held any more, and on a host that still holds one the useful
30
+ * question is only how long that host is willing to wait in total.
31
+ */
32
+ export const WAITER_CEILING_SECONDS = 480;
33
+ /**
34
+ * The wire contract's shortest reply window. A question submitted with less
35
+ * than this is rejected outright, and accepting one would in any case let the
36
+ * server go on taking an answer after the waiter that owns it has returned.
30
37
  */
31
- const STOP_BUDGET_SECONDS = HOOK_TIMING.totalSeconds;
32
- const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds;
38
+ const MIN_REPLY_WINDOW_SECONDS = 60;
33
39
  /**
34
40
  * The terminal-first wait: the question sits in the terminal for
35
41
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
36
42
  * devices.
37
43
  *
38
- * Holding a blocking Stop hook open is normally hostile — while it blocks, the
39
- * harness cannot show its prompt either, so a user wanting to answer locally is
40
- * locked out. What makes it safe is that the wait is abandoned the moment the
41
- * user touches the keyboard or mouse. The block therefore only ever persists
42
- * while they are demonstrably not using the machine, which costs them nothing.
43
- *
44
- * That safety depends entirely on the idle signal, so with no idle source this
45
- * refuses to wait at all rather than holding a terminal it cannot monitor.
44
+ * A plain timer, and nothing else. It once polled an idle signal so it could
45
+ * abandon the wait the moment the user touched the keyboard — necessary while
46
+ * the wait blocked the terminal, because a user wanting to answer locally was
47
+ * otherwise locked out of their own prompt. Nothing is monopolised now, so
48
+ * there is nothing to watch for and no machine this cannot run on.
46
49
  *
47
- * With `require_idle` off the whole calculus changes: the user has said they
48
- * want the question to reach them whether or not they are at the keyboard, so
49
- * there is nothing to watch for and this becomes what its name always claimed —
50
- * a plain timer. It then works on machines with no idle source too.
50
+ * The window is measured from registration, not from the turn's end: a
51
+ * question the agent asked five minutes ago while it kept working has already
52
+ * served its wait. A stamp from the future would otherwise consume the whole
53
+ * ceiling and leave no room to answer, so it starts no later than now.
51
54
  */
52
- export async function awaitGrace(ctx, askedAt) {
53
- const threshold = ctx.config.away_after_seconds.value;
54
- // Keep a real device-answer window even when a user selects maximum grace.
55
- const graceSeconds = Math.min(ctx.config.ask_grace_seconds.value, HOOK_TIMING.maxGraceSeconds);
56
- // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
57
- // future would wait far past the hook's budget, one from the distant past
58
- // would skip the terminal-first window the user asked for entirely. Anything
59
- // outside a plausible range restarts the window from now.
60
- const elapsed = ctx.now() - askedAt;
61
- const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now();
62
- const deadline = start + graceSeconds * 1000;
63
- for (;;) {
64
- if (ctx.config.require_idle.value) {
65
- const idle = ctx.idleSeconds();
66
- if (idle === null)
67
- return 'no-signal';
68
- if (idle < threshold)
69
- return 'user-returned';
70
- }
71
- if (ctx.now() >= deadline)
72
- return 'absent';
73
- await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())));
74
- }
55
+ async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
56
+ const graceSeconds = ctx.config.ask_grace_seconds.value;
57
+ if (graceSeconds <= 0)
58
+ return;
59
+ const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt);
60
+ const remaining = until - ctx.now();
61
+ if (remaining > 0)
62
+ await ctx.sleep(remaining);
75
63
  }
76
64
  function sessionStatePath(sessionId, env) {
77
65
  return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`);
@@ -179,7 +167,7 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
179
167
  * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
180
168
  * no trustworthy PID fall back to an age limit.
181
169
  */
182
- const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000;
170
+ const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
183
171
  const heldClaims = new Map();
184
172
  export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
185
173
  const file = claimPath(sessionId, env);
@@ -354,9 +342,16 @@ function readStoredProjectSessionPointers(file) {
354
342
  export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
355
343
  return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null;
356
344
  }
357
- export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
345
+ /**
346
+ * Every session that has fired a hook in this directory recently, newest first.
347
+ *
348
+ * This is the only local record of which harness sessions are actually alive
349
+ * here, which makes it the tiebreaker when the environment alone cannot say
350
+ * which harness owns the current shell.
351
+ */
352
+ export function readLiveProjectSessionPointers(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
358
353
  const file = projectSessionPointerPath(cwd, env);
359
- const live = readStoredProjectSessionPointers(file)
354
+ return readStoredProjectSessionPointers(file)
360
355
  .filter((entry) => now - entry.updatedAt <= maxAgeMs)
361
356
  .filter((entry) => {
362
357
  try {
@@ -373,32 +368,20 @@ export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 *
373
368
  return false;
374
369
  }
375
370
  })
376
- .sort((left, right) => right.updatedAt - left.updatedAt);
377
- const latest = live[0];
371
+ .sort((left, right) => right.updatedAt - left.updatedAt)
372
+ .map((entry) => ({
373
+ sessionId: entry.sessionId,
374
+ harness: entry.harness,
375
+ updatedAt: entry.updatedAt,
376
+ }));
377
+ }
378
+ export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
379
+ const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0];
378
380
  return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness };
379
381
  }
380
382
  export function readMatchingProjectSessionPointer(cwd, env, now, sessionId, harness, maxAgeMs = 24 * 3600 * 1000) {
381
- const file = projectSessionPointerPath(cwd, env);
382
- const match = readStoredProjectSessionPointers(file).find((entry) => entry.sessionId === sessionId &&
383
- entry.harness === harness &&
384
- now - entry.updatedAt <= maxAgeMs);
385
- if (match === undefined)
386
- return null;
387
- try {
388
- // A pointer is routing evidence only while its session state still exists
389
- // and parses. SessionEnd and explicit cleanup therefore invalidate it even
390
- // when a crash prevented the pointer file itself from being removed.
391
- const sessionFile = sessionStatePath(match.sessionId, env);
392
- if (!existsSync(sessionFile))
393
- return null;
394
- const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
395
- if (typeof state !== 'object' || state === null || Array.isArray(state))
396
- return null;
397
- return { sessionId: match.sessionId, harness: match.harness };
398
- }
399
- catch {
400
- return null;
401
- }
383
+ const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find((entry) => entry.sessionId === sessionId && entry.harness === harness);
384
+ return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness };
402
385
  }
403
386
  function clearMatchingProjectSession(cwd, env, sessionId) {
404
387
  const file = projectSessionPointerPath(cwd, env);
@@ -417,61 +400,14 @@ function clearMatchingProjectSession(cwd, env, sessionId) {
417
400
  })}\n`);
418
401
  });
419
402
  }
420
- /**
421
- * Absent means the user is not at this machine's keyboard.
422
- *
423
- * OS idle time answers that directly, so where it exists it decides alone.
424
- * Silence since the user's last prompt is only ever a proxy for it, and a poor
425
- * one — it is wrong in both directions:
426
- *
427
- * - Too long: it counts the agent's own turn, so a user watching a build was
428
- * read as absent and had the question pushed at them.
429
- * - Too short: a session that has just been spawned always has a fresh
430
- * prompt, so its FIRST question could never escalate however long its
431
- * owner had been gone. That is the "kick off some agents and
432
- * walk away" case this feature mainly exists for, and requiring both
433
- * signals to agree is what broke it.
434
- *
435
- * The proxy therefore survives only as the fallback for machines with no idle
436
- * source. There, never having seen a prompt is not evidence of absence — it is
437
- * a missing `UserPromptSubmit` hook — so it resolves to "present".
438
- *
439
- * Answering from a companion device does not make the user present; only touching this
440
- * machine does. Answering on a device is evidence of being away from it.
441
- *
442
- * All of which only matters if presence is being consulted at all. With
443
- * `require_idle` off the user has said their whereabouts are not a
444
- * precondition, so this stops guessing and answers yes.
445
- */
446
- export function isUserAway(state, config, now, idleSeconds) {
447
- if (!config.require_idle.value)
448
- return true;
449
- const threshold = config.away_after_seconds.value;
450
- if (idleSeconds !== null)
451
- return idleSeconds >= threshold;
452
- if (state.last_prompt_at === undefined)
453
- return false;
454
- const silence = now - state.last_prompt_at;
455
- // Both signs of a clock jump produce a wrong answer here, and the fallback
456
- // path has no monotonic reference to check against: a forward jump (NTP
457
- // correction, VM resume) hijacks a terminal whose user is sitting right
458
- // there, and a backward one suppresses escalation for someone genuinely
459
- // gone. Neither delta is evidence of anything, so it resolves the way every
460
- // other absence of evidence does — present.
461
- if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS)
462
- return false;
463
- return silence >= threshold * 1000;
403
+ function gate(ctx, verdict, reason, data = {}) {
404
+ ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data });
464
405
  }
465
406
  /**
466
- * Beyond this, silence says more about the clock or an abandoned session file
467
- * than about the user. Matches the project pointer's staleness horizon.
407
+ * One round of the waiter's reply poll. Short enough that several questions
408
+ * still share one wall clock, long enough not to hammer the server.
468
409
  */
469
- const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000;
470
- function gate(ctx, verdict, reason, data = {}) {
471
- ctx.log?.info('hook.gate', { verdict, reason, ...data });
472
- }
473
- /** Re-check local presence at this cadence while a pushed question is waiting. */
474
- const REPLY_PRESENCE_POLL_SECONDS = 5;
410
+ const REPLY_POLL_SECONDS = 5;
475
411
  /**
476
412
  * Push a question and block for the answer.
477
413
  *
@@ -531,29 +467,26 @@ async function submitQuestion(ctx, intent) {
531
467
  return receipt;
532
468
  }
533
469
  /**
534
- * One blocking wait across every live question. Each round polls all of them
535
- * concurrently, so several questions cost the same wall clock as one; the
536
- * first round that finds any reply returns everything found in that round, and
537
- * whatever was not answered stays registered.
470
+ * The waiter's wait: one bounded poll across every live question at once.
471
+ *
472
+ * Each round polls all of them concurrently, so several questions cost the
473
+ * same wall clock as one; the first round that finds any reply returns
474
+ * everything found in that round, and whatever was not answered stays
475
+ * registered. A question whose polling is permanently rejected drops out of
476
+ * the round rather than failing the whole wait.
538
477
  */
539
- async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds, monitorPresence = true) {
478
+ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
540
479
  const deadline = ctx.now() + timeoutSeconds * 1000;
541
480
  let degraded = false;
542
481
  let firstPoll = true;
543
482
  const permanentFailures = new Map();
544
483
  for (;;) {
545
- if (monitorPresence && ctx.config.require_idle.value) {
546
- const idle = ctx.idleSeconds();
547
- if (idle !== null && idle < ctx.config.away_after_seconds.value) {
548
- return { byRequest: new Map(), degraded, userReturned: true, permanentFailures };
549
- }
550
- }
551
484
  const remainingMs = Math.max(0, deadline - ctx.now());
552
485
  if (!firstPoll && remainingMs === 0) {
553
- return { byRequest: new Map(), degraded, userReturned: false, permanentFailures };
486
+ return { byRequest: new Map(), degraded, permanentFailures };
554
487
  }
555
488
  firstPoll = false;
556
- const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
489
+ const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
557
490
  const results = await Promise.all(requestIds
558
491
  .filter((requestId) => !permanentFailures.has(requestId))
559
492
  .map(async (requestId) => {
@@ -582,7 +515,7 @@ async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds, monitor
582
515
  byRequest.set(result.requestId, result.replies);
583
516
  }
584
517
  if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
585
- return { byRequest, degraded, userReturned: false, permanentFailures };
518
+ return { byRequest, degraded, permanentFailures };
586
519
  }
587
520
  }
588
521
  }
@@ -840,7 +773,15 @@ export async function drainOrphanRetirements(ctx, env, now) {
840
773
  }
841
774
  const LATE_STOP_POLL_SECONDS = 4;
842
775
  const LATE_PROMPT_POLL_SECONDS = 3;
843
- const MAX_CONTINUATION_COUNT = 3;
776
+ /**
777
+ * The one bound on answer deliveries, for every route.
778
+ *
779
+ * It caps two things that both used to be able to run away: how many times a
780
+ * single accepted answer may be handed to a route before the journal gives up
781
+ * on it, and how many answer-driven continuation generations one chain may
782
+ * produce.
783
+ */
784
+ export const MAX_CONTINUATION_COUNT = 3;
844
785
  /** One bounded recovery poll for a question that outlived its original wait. */
845
786
  async function pollPendingReply(ctx, pending, timeoutSeconds) {
846
787
  if (pending.request_id === undefined) {
@@ -878,6 +819,7 @@ function finalizedAnswer(finalized) {
878
819
  function reportAnswer(ctx, notes, answered, late) {
879
820
  ctx.log?.info('hook.answer', {
880
821
  answered: true,
822
+ stage: 'queued',
881
823
  ...(late ? { late: true } : {}),
882
824
  request_id: answered.pending.request_id,
883
825
  device: answered.reply.device_name,
@@ -931,18 +873,27 @@ function pendingHasChoices(pending) {
931
873
  * it. Free-text answers can arrive in parts, and every part reaches the
932
874
  * agent in the order it was written, so it can tell expansion from
933
875
  * correction itself.
876
+ *
877
+ * The continuation repeats only identity the agent actually registered and
878
+ * the answer the server accepted. It never invents trust, urgency, permission,
879
+ * or approval claims for the transport to assert.
934
880
  */
935
- function answerContext(replies, hadChoices) {
881
+ function answerContext(answered) {
882
+ const { pending, replies } = answered;
936
883
  const latest = replies.at(-1);
937
884
  if (latest === undefined)
938
885
  return 'Notifai — no answer was recorded.';
939
- if (replies.length === 1 || hadChoices) {
940
- return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`;
941
- }
942
- const parts = replies.map((reply) => `"${reply.text}"`).join(', then ');
943
- return (`Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
944
- `in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
945
- 'Continue with that answer.');
886
+ const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))];
887
+ const identity = answeredQuestionIds.length === 0
888
+ ? ''
889
+ : `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `;
890
+ const question = `question ${JSON.stringify(pending.question)}`;
891
+ if (replies.length === 1 || pendingHasChoices(pending)) {
892
+ return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`;
893
+ }
894
+ const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ');
895
+ return (`Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
896
+ `in the order written: ${parts}. Later parts extend or correct earlier ones.`);
946
897
  }
947
898
  /**
948
899
  * Every answer that has arrived, as one message. Several registered questions
@@ -955,17 +906,18 @@ function answersContext(answered, remaining) {
955
906
  ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
956
907
  : '';
957
908
  if (answered.length === 1) {
958
- const only = answered[0];
959
- return answerContext(only.replies, pendingHasChoices(only.pending)) + tail;
909
+ return answerContext(answered[0]) + tail;
960
910
  }
961
911
  const lines = answered.map(({ pending, replies }) => {
962
912
  const latest = replies.at(-1);
963
913
  const answer = replies.length === 1 || pendingHasChoices(pending)
964
- ? `"${latest.text}"`
965
- : `${replies.map((reply) => `"${reply.text}"`).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`;
966
- return `- "${pending.question}" → ${answer} (from ${latest.device_name})`;
914
+ ? JSON.stringify(latest.text)
915
+ : `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`;
916
+ const ids = [...new Set(latest.answers.map((entry) => entry.question_id))];
917
+ const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `;
918
+ return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`;
967
919
  });
968
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`;
920
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`;
969
921
  }
970
922
  function stopAnswerOutput(context) {
971
923
  return JSON.stringify({
@@ -980,15 +932,16 @@ function stopAnswerOutput(context) {
980
932
  * UserPromptSubmit answers ride the user's new turn.
981
933
  */
982
934
  function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
935
+ const accepted = {
936
+ answers: answered,
937
+ remaining,
938
+ recorded_at: ctx.now(),
939
+ };
983
940
  updateSessionState(sessionId, ctx.env, (current) => {
984
941
  const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
985
942
  const next = {
986
943
  ...current,
987
- accepted: {
988
- answers: answered,
989
- remaining,
990
- recorded_at: ctx.now(),
991
- },
944
+ accepted,
992
945
  };
993
946
  if (pendingRemaining.length > 0)
994
947
  next.pending = pendingRemaining;
@@ -996,8 +949,20 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
996
949
  delete next.pending;
997
950
  return next;
998
951
  });
952
+ return accepted;
999
953
  }
1000
- /** Settle only after a successor hook proves the harness received the answer. */
954
+ /** Amend the live accepted record in place, without resurrecting a settled one. */
955
+ function amendAcceptedAnswers(ctx, sessionId, amend) {
956
+ updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
957
+ }
958
+ /**
959
+ * Retire the answered questions and close the journal.
960
+ *
961
+ * Called once an answer's delivery is acknowledged — by the route's own write,
962
+ * or by the successor Stop of a blocking continuation. The continuation counter
963
+ * grows with each settled generation, so the cap that bounds chained
964
+ * answer-to-question loops is a real count rather than a constant 1.
965
+ */
1001
966
  function settleAcceptedAnswers(ctx, sessionId, accepted) {
1002
967
  const retirements = accepted.answers
1003
968
  .map(({ pending }) => retiringQuestion(pending, 'answered'))
@@ -1015,7 +980,7 @@ function settleAcceptedAnswers(ctx, sessionId, accepted) {
1015
980
  delete next.accepted;
1016
981
  next.continuation = {
1017
982
  answered_at: accepted.recorded_at,
1018
- count: accepted.continuation_count ?? 1,
983
+ count: (current.continuation?.count ?? 0) + 1,
1019
984
  };
1020
985
  return next;
1021
986
  });
@@ -1068,7 +1033,9 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1068
1033
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1069
1034
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
1070
1035
  }
1071
- notes.push('a device answer is safely journaled; the next Stop will deliver it');
1036
+ notes.push(state.accepted.delivered_at === undefined
1037
+ ? 'a device answer is safely journaled; the next Stop will deliver it'
1038
+ : 'a device answer has already been delivered; the next Stop will close it out');
1072
1039
  return { notes };
1073
1040
  }
1074
1041
  const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
@@ -1115,7 +1082,14 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1115
1082
  ...(unasked.length > 0 ? { pending: unasked } : {}),
1116
1083
  ...(retiring.length > 0 ? { retiring } : {}),
1117
1084
  ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1118
- ...(current.continuation === undefined ? {} : { continuation: current.continuation }),
1085
+ // The user typed, so whatever chain of answer-driven continuations was
1086
+ // running, a human has taken the turn and the consecutive count starts
1087
+ // over. Only a real prompt reaches here: the journal branch above returns
1088
+ // first, and a wake route's injected message always arrives while its own
1089
+ // answer is still journaled, so it can never pass for the user's presence.
1090
+ ...(current.continuation === undefined
1091
+ ? {}
1092
+ : { continuation: { ...current.continuation, count: 0 } }),
1119
1093
  };
1120
1094
  });
1121
1095
  // The bridge that lets a plain `notifai ask` find the hook's canonical
@@ -1147,14 +1121,27 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1147
1121
  * `notifai ask`. Guessing from the last assistant message was the alternative
1148
1122
  * and it is not worth it: a false positive here hijacks the terminal.
1149
1123
  */
1150
- export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + STOP_BUDGET_SECONDS * 1000) {
1151
- const notes = [];
1152
- const hardDeadlineAt = processDeadlineAt;
1124
+ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute()) {
1153
1125
  const sessionId = envelope.session_id;
1154
1126
  if (!sessionId) {
1155
- gate(ctx, 'held', 'no-session');
1156
- return { notes };
1157
- }
1127
+ gate(ctx, 'held', 'no-session', { stage: 'queued' });
1128
+ return { notes: [] };
1129
+ }
1130
+ return runEscalationWaiter(ctx, {
1131
+ sessionId,
1132
+ envelope,
1133
+ route,
1134
+ processDeadlineAt,
1135
+ });
1136
+ }
1137
+ /**
1138
+ * Own one registered question pipeline from its terminal-first timer through
1139
+ * its final delivery or retirement. Hosts differ only in the injected route.
1140
+ */
1141
+ export async function runEscalationWaiter(ctx, options) {
1142
+ const notes = [];
1143
+ const { sessionId, envelope } = options;
1144
+ const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000;
1158
1145
  // This event marker is diagnostic evidence in its own right. Record it
1159
1146
  // before every early return so doctor can distinguish a working Stop route
1160
1147
  // from the prompt-only state that previously looked healthy.
@@ -1162,49 +1149,39 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
1162
1149
  ...current,
1163
1150
  last_stop_at: ctx.now(),
1164
1151
  }));
1165
- // Claim before any reply poll, not only before the grace window. Two racing
1166
- // Stops can both observe the same accepted journal or live request. An
1167
- // atomic state update cannot retract duplicate block output a loser already
1168
- // built, so one session claim owns the complete Stop decision: accepted
1169
- // replay/ack, late-answer collection, cleanup, and any new escalation.
1170
- //
1171
- // Real clock, deliberately, not `ctx.now` — the claim answers "is another
1172
- // process alive right now", which the injectable clock cannot speak to. It
1173
- // is also the only clock the *other* process shares.
1152
+ // The waiter, not its host, owns this lease. It spans grace, submission,
1153
+ // polling, close fencing, route delivery, and every retirement path.
1154
+ // Real clock, deliberately, not `ctx.now` — the claim answers whether
1155
+ // another process is alive right now, which an injected clock cannot know.
1174
1156
  if (!claimQuestionPush(sessionId, ctx.env)) {
1175
- gate(ctx, 'held', 'claimed-elsewhere');
1157
+ gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' });
1176
1158
  notes.push('another hook is already handling this question');
1177
1159
  return { notes };
1178
1160
  }
1161
+ let settledAnswerThisPass = false;
1179
1162
  try {
1180
1163
  let state = readSessionState(sessionId, ctx.env);
1181
1164
  if (state.accepted !== undefined) {
1182
1165
  const accepted = state.accepted;
1183
- // A recursive Stop is emitted only after the previous Stop's blocking
1184
- // output was admitted and the continued model turn ran. That event is
1185
- // the acknowledgment: persisting a separate "offered" bit before stdout
1186
- // would recreate the crash gap this journal exists to close.
1187
- const deliveryProven = envelope.stop_hook_active === true;
1166
+ // Acknowledgement is per route, because routes end in different places.
1167
+ //
1168
+ // A blocking continuation ends at this process's stdout, which the
1169
+ // harness reads only after the process exits: the recursive Stop's
1170
+ // `stop_hook_active` is the acknowledgement, and persisting a separate
1171
+ // "offered" bit before stdout would recreate the crash gap this journal
1172
+ // exists to close. An out-of-band route ends at a write this process made
1173
+ // itself and recorded here. A socket write is not proof the model acted
1174
+ // on the answer — delivery is not consumption — but nothing later can
1175
+ // prove more, and an answer redelivered without end is strictly worse
1176
+ // than one settled on delivery. So a recorded delivery settles.
1177
+ const deliveryProven = accepted.delivered_at !== undefined || envelope.stop_hook_active === true;
1188
1178
  if (!deliveryProven) {
1189
- updateSessionState(sessionId, ctx.env, (current) => {
1190
- if (current.accepted === undefined)
1191
- return current;
1192
- return {
1193
- ...current,
1194
- accepted: {
1195
- ...current.accepted,
1196
- continuation_count: (current.continuation?.count ?? 0) + 1,
1197
- },
1198
- };
1199
- });
1200
1179
  for (const answer of accepted.answers)
1201
1180
  reportAnswer(ctx, notes, answer, true);
1202
- return {
1203
- stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
1204
- notes,
1205
- };
1181
+ return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes);
1206
1182
  }
1207
1183
  settleAcceptedAnswers(ctx, sessionId, accepted);
1184
+ settledAnswerThisPass = true;
1208
1185
  state = readSessionState(sessionId, ctx.env);
1209
1186
  }
1210
1187
  const pending = pendingList(state);
@@ -1216,17 +1193,114 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
1216
1193
  if (swept.length > 0) {
1217
1194
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
1218
1195
  }
1219
- gate(ctx, 'held', 'no-question');
1196
+ gate(ctx, 'held', 'no-question', { stage: 'queued' });
1220
1197
  return { notes };
1221
1198
  }
1222
- return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt);
1199
+ return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, options.route,
1200
+ // Every route's successor turn-end, named the same way: the harness flag
1201
+ // for a blocking continuation, and the settle above for a route that woke
1202
+ // a brand-new turn out of band.
1203
+ envelope.stop_hook_active === true || settledAnswerThisPass);
1223
1204
  }
1224
1205
  finally {
1225
1206
  releaseQuestionPush(sessionId, ctx.env);
1226
1207
  }
1227
1208
  }
1209
+ /**
1210
+ * The blocking Stop hook's own last meter: the answer becomes this process's
1211
+ * stdout, and the harness admits it as a new user turn. Exported because a
1212
+ * host adapter that has other routes still falls back to this one.
1213
+ */
1214
+ export function hookContinuationRoute() {
1215
+ return {
1216
+ kind: 'hook-continuation',
1217
+ deliver: async (event) => ({
1218
+ stdout: stopAnswerOutput(event.context),
1219
+ notes: [],
1220
+ // The harness reads this stdout after the process exits, so only the
1221
+ // successor Stop can acknowledge it.
1222
+ acknowledgement: 'stdout',
1223
+ }),
1224
+ };
1225
+ }
1226
+ /**
1227
+ * Hand one accepted answer to a route and record what the attempt proved.
1228
+ *
1229
+ * Every delivery on every route passes through here, which is what makes the
1230
+ * cap below a backstop rather than a suggestion. A guard only one route
1231
+ * consults is not a backstop: that is how a single answer once reached a
1232
+ * session 250 times.
1233
+ */
1234
+ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
1235
+ const { answers: answered, remaining } = accepted;
1236
+ const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
1237
+ const attempt = (accepted.delivery_attempts ?? 0) + 1;
1238
+ if (attempt > MAX_CONTINUATION_COUNT) {
1239
+ gate(ctx, 'held', 'delivery-limit', {
1240
+ route: route.kind,
1241
+ attempts: accepted.delivery_attempts ?? 0,
1242
+ limit: MAX_CONTINUATION_COUNT,
1243
+ request_ids: requestIds,
1244
+ });
1245
+ // Settle rather than journal for ever. The answer has been offered as often
1246
+ // as any working route could need, it is already in the notes of each of
1247
+ // those turns, and the questions it answered still deserve retirement.
1248
+ settleAcceptedAnswers(ctx, sessionId, accepted);
1249
+ notes.push(`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`);
1250
+ return { notes };
1251
+ }
1252
+ // Count the attempt before making it: an attempt that dies mid-delivery still
1253
+ // happened, and a counter written afterwards could be evaded by crashing.
1254
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1255
+ ...current,
1256
+ delivery_attempts: attempt,
1257
+ }));
1258
+ ctx.log?.info('hook.gate', {
1259
+ verdict: 'proceeding',
1260
+ reason: 'answered',
1261
+ stage: 'routed',
1262
+ route: route.kind,
1263
+ answers: answered.length,
1264
+ request_ids: requestIds,
1265
+ journal_recorded_at: accepted.recorded_at,
1266
+ delivery_attempt: attempt,
1267
+ });
1268
+ const delivered = await route.deliver({
1269
+ context: answersContext(answered, remaining),
1270
+ answers: answered.length,
1271
+ remaining,
1272
+ request_ids: requestIds,
1273
+ journal_recorded_at: accepted.recorded_at,
1274
+ });
1275
+ const deliveredRoute = typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind;
1276
+ const deliveredStage = typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered';
1277
+ if (delivered.acknowledgement === 'delivered') {
1278
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1279
+ ...current,
1280
+ delivered_at: ctx.now(),
1281
+ delivered_route: deliveredRoute,
1282
+ }));
1283
+ }
1284
+ ctx.log?.info('hook.answer', {
1285
+ answered: true,
1286
+ stage: deliveredStage,
1287
+ route: deliveredRoute,
1288
+ acknowledgement: delivered.acknowledgement,
1289
+ delivery_attempt: attempt,
1290
+ request_ids: requestIds,
1291
+ answers: answered.length,
1292
+ journal_recorded_at: accepted.recorded_at,
1293
+ ...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
1294
+ });
1295
+ const outcome = { notes: [...notes, ...(delivered.notes ?? [])] };
1296
+ if (delivered.stdout !== undefined)
1297
+ outcome.stdout = delivered.stdout;
1298
+ if (delivered.log !== undefined)
1299
+ outcome.log = delivered.log;
1300
+ return outcome;
1301
+ }
1228
1302
  /** The complete per-session Stop decision, after this process owns the claim. */
1229
- async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt) {
1303
+ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
1230
1304
  const live = pending.filter((entry) => entry.request_id !== undefined);
1231
1305
  const unasked = pending.filter((entry) => entry.request_id === undefined);
1232
1306
  let liveToEscalate = live;
@@ -1259,16 +1333,12 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1259
1333
  }
1260
1334
  return { ...entry, replies, reply: replies.at(-1) };
1261
1335
  });
1262
- stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
1263
- gate(ctx, 'proceeding', 'answered', { answers: authoritative.length });
1336
+ const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
1264
1337
  for (const answer of authoritative)
1265
1338
  reportAnswer(ctx, notes, answer, true);
1266
1339
  // Anything still unasked rides the next Stop: the agent is being resumed
1267
1340
  // with answers right now, and may not even need the rest afterwards.
1268
- return {
1269
- stdout: stopAnswerOutput(answersContext(authoritative, pending.length - authoritative.length - permanentlyRejected.length)),
1270
- notes,
1271
- };
1341
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
1272
1342
  }
1273
1343
  const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1274
1344
  liveToEscalate = recoverableLive;
@@ -1279,7 +1349,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1279
1349
  // questions remain. The next Stop is their successor owner: keep waiting
1280
1350
  // to the original absolute deadline instead of polling for four seconds
1281
1351
  // and abandoning them.
1282
- return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt);
1352
+ return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt, route);
1283
1353
  }
1284
1354
  // Questions registered after the earlier push still owe the user their
1285
1355
  // notification; fall through and escalate just those.
@@ -1296,14 +1366,22 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1296
1366
  notes.push('already continuing from an answer; not asking again this turn');
1297
1367
  return { notes };
1298
1368
  }
1299
- if (continuation.count >= MAX_CONTINUATION_COUNT) {
1300
- gate(ctx, 'held', 'continuation-limit', {
1301
- count: continuation.count,
1302
- limit: MAX_CONTINUATION_COUNT,
1303
- });
1304
- notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
1305
- return { notes };
1306
- }
1369
+ }
1370
+ // The cap is deliberately not keyed to `stop_hook_active`. That flag names one
1371
+ // route's continuation; a route that wakes a brand-new turn never sets it, and
1372
+ // a limit only one route consults bounds only that route. `continuingFromAnswer`
1373
+ // is the same event named for every route, so the chain of answer → follow-up
1374
+ // question → answer is bounded whatever delivered it.
1375
+ if (continuingFromAnswer &&
1376
+ state.continuation !== undefined &&
1377
+ state.continuation.count >= MAX_CONTINUATION_COUNT) {
1378
+ gate(ctx, 'held', 'continuation-limit', {
1379
+ count: state.continuation.count,
1380
+ limit: MAX_CONTINUATION_COUNT,
1381
+ route: route.kind,
1382
+ });
1383
+ notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
1384
+ return { notes };
1307
1385
  }
1308
1386
  // Silent to the user by design — they switched routing off, so saying so on
1309
1387
  // every turn would be nagging about their own setting. That silence is also
@@ -1313,61 +1391,46 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1313
1391
  gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
1314
1392
  return { notes };
1315
1393
  }
1316
- const idle = ctx.idleSeconds();
1317
- if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
1318
- gate(ctx, 'held', 'user-present', {
1319
- idle_seconds: idle,
1320
- away_after_seconds: ctx.config.away_after_seconds.value,
1321
- require_idle: ctx.config.require_idle.value,
1322
- last_prompt_at: state.last_prompt_at ?? null,
1323
- });
1324
- notes.push('you are at the keyboard; leaving the question in the terminal');
1325
- return { notes };
1326
- }
1327
1394
  gate(ctx, 'proceeding', 'proceeding', {
1328
1395
  unasked: unasked.length,
1329
1396
  already_live: liveToEscalate.length,
1330
- idle_seconds: idle,
1331
1397
  grace_seconds: ctx.config.ask_grace_seconds.value,
1332
1398
  });
1333
- return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt);
1399
+ return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt, route);
1334
1400
  }
1335
1401
  /** The escalation itself, split out so the claim is released on every path. */
1336
- async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt) {
1337
- // Away right now, but the questions still owe the user their terminal-first
1338
- // window before anything reaches their devices — measured from the oldest
1339
- // registration, because that is the question that has waited longest.
1340
- const budgetStartedAt = ctx.now();
1341
- const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= budgetStartedAt
1402
+ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt, route) {
1403
+ // The questions still owe the user their terminal-first window before
1404
+ // anything reaches their devices — measured from the oldest registration,
1405
+ // because that is the question that has waited longest.
1406
+ const startedAt = ctx.now();
1407
+ const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
1342
1408
  ? []
1343
1409
  : [entry.reply_deadline_at]);
1344
- const budgetDeadlineAt = Math.min(hardDeadlineAt - HOOK_TIMING.finalizationReserveSeconds * 1000, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
1410
+ const ceilingAt = Math.min(hardDeadlineAt, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
1345
1411
  if (unasked.length > 0 && alreadyLive.length === 0) {
1346
1412
  const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
1347
- const grace = await awaitGrace(ctx, oldest);
1348
- ctx.log?.debug('hook.gate', { verdict: 'grace', reason: grace, waited_from: oldest });
1349
- if (grace === 'user-returned') {
1350
- gate(ctx, 'held', 'user-returned', { grace_seconds: ctx.config.ask_grace_seconds.value });
1351
- notes.push('you came back before the wait elapsed; leaving the questions in the terminal');
1352
- return { notes };
1353
- }
1354
- if (grace === 'no-signal') {
1355
- notes.push('no idle signal on this machine; asking now rather than holding the terminal');
1356
- }
1413
+ await awaitTerminalFirstWindow(ctx, oldest, ceilingAt);
1414
+ ctx.log?.debug('hook.gate', {
1415
+ verdict: 'grace',
1416
+ reason: 'elapsed',
1417
+ waited_from: oldest,
1418
+ grace_seconds: ctx.config.ask_grace_seconds.value,
1419
+ });
1357
1420
  }
1358
1421
  // Phase one: every registered question reaches the user's devices, each as
1359
1422
  // its own notification — one ask never stands in for another.
1360
1423
  const submitted = [];
1361
1424
  for (const entry of unasked) {
1362
- const remainingSeconds = Math.floor((budgetDeadlineAt - ctx.now()) / 1000);
1363
- if (remainingSeconds <
1364
- MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds) {
1365
- notes.push('the Stop budget cannot fit device resolution, submission, and a full reply window; leaving this question in the terminal');
1425
+ // The reply window is the question's own: whatever is left of the waiter's
1426
+ // ceiling, granted to the server so it stops accepting answers at the same
1427
+ // moment this owner stops listening for them.
1428
+ const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000);
1429
+ if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
1430
+ notes.push('too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal');
1366
1431
  continue;
1367
1432
  }
1368
- const replyWindowSeconds = Math.min(ctx.config.hook_reply_timeout_seconds.value, remainingSeconds - HOOK_TIMING.submissionReserveSeconds);
1369
- const ownerDeadlineAt = Math.min(budgetDeadlineAt, ctx.now() +
1370
- (replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000);
1433
+ const ownerDeadlineAt = ceilingAt;
1371
1434
  const questions = pendingQuestions(entry);
1372
1435
  let intent = entry.submission;
1373
1436
  if (intent === undefined) {
@@ -1410,12 +1473,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1410
1473
  // The frozen wire identity survives a crashed owner, but its local owner
1411
1474
  // lease does not. Re-arm only the local deadline; request id, key,
1412
1475
  // targets, and draft remain byte-identical for idempotent replay.
1413
- const frozenReplyWindow = intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds;
1414
- intent = {
1415
- ...intent,
1416
- owner_deadline_at: Math.min(budgetDeadlineAt, ctx.now() +
1417
- (frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000),
1418
- };
1476
+ intent = { ...intent, owner_deadline_at: ceilingAt };
1419
1477
  const rearmed = intent;
1420
1478
  updateSessionState(sessionId, ctx.env, (current) => {
1421
1479
  const list = pendingList(current);
@@ -1428,13 +1486,12 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1428
1486
  });
1429
1487
  }
1430
1488
  if (ctx.now() >= intent.owner_deadline_at) {
1431
- notes.push('the Stop budget ended before submission; preserving the frozen intent');
1489
+ notes.push('the waiter ceiling ended before submission; preserving the frozen intent');
1432
1490
  continue;
1433
1491
  }
1434
1492
  const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000);
1435
- if (beforeSubmitSeconds <
1436
- MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2) {
1437
- notes.push('device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window');
1493
+ if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
1494
+ notes.push('device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts');
1438
1495
  continue;
1439
1496
  }
1440
1497
  let receipt;
@@ -1536,13 +1593,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1536
1593
  }
1537
1594
  if (staleAnswers.length > 0) {
1538
1595
  const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
1539
- stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
1596
+ const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
1540
1597
  for (const answer of staleAnswers)
1541
1598
  reportAnswer(ctx, notes, answer, true);
1542
- return {
1543
- stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
1544
- notes,
1545
- };
1599
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
1546
1600
  }
1547
1601
  const waitingOn = [
1548
1602
  ...alreadyLive.filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now()),
@@ -1550,10 +1604,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1550
1604
  ];
1551
1605
  if (waitingOn.length === 0)
1552
1606
  return { notes };
1553
- // Phase two: one blocking wait across everything live, old and new alike.
1554
- const ownerDeadline = Math.min(budgetDeadlineAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
1607
+ // Phase two: one wait across everything live, old and new alike.
1608
+ const ownerDeadline = Math.min(ceilingAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
1555
1609
  const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000));
1556
- const waited = await waitForAnyReplyWhileAway(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds, ctx.harness !== 'opencode');
1610
+ const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
1557
1611
  const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
1558
1612
  if (permanentFailure !== null)
1559
1613
  notes.push(permanentFailure);
@@ -1570,34 +1624,28 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1570
1624
  .map((entry) => entry.pending);
1571
1625
  const unproven = finalized.filter((entry) => entry.response === null);
1572
1626
  if (confirmedSilent.length > 0) {
1573
- await retirePendings(ctx, envelope, sessionId, confirmedSilent, waited.userReturned ? 'answered_elsewhere' : 'expired');
1627
+ await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired');
1574
1628
  }
1575
1629
  if (unproven.length > 0) {
1576
1630
  notes.push(`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`);
1577
1631
  }
1578
1632
  if (finalAnswers.length > 0) {
1579
1633
  const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length);
1580
- stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
1634
+ const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
1581
1635
  for (const answer of finalAnswers)
1582
1636
  reportAnswer(ctx, notes, answer, false);
1583
- return {
1584
- stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
1585
- notes,
1586
- };
1637
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
1587
1638
  }
1588
1639
  const requestIdSummary = summarizeRequestIds(waitingOn);
1589
1640
  ctx.log?.info('hook.answer', {
1590
1641
  answered: false,
1642
+ stage: 'queued',
1591
1643
  request_ids: requestIdSummary.ids,
1592
- user_returned: waited.userReturned,
1593
1644
  degraded: waited.degraded,
1594
1645
  permanent_failures: waited.permanentFailures.size,
1595
1646
  waited_seconds: timeoutSeconds,
1596
1647
  });
1597
- if (waited.userReturned) {
1598
- notes.push(`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})`);
1599
- }
1600
- else if (waited.permanentFailures.size === 0) {
1648
+ if (waited.permanentFailures.size === 0) {
1601
1649
  notes.push(waited.degraded
1602
1650
  ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
1603
1651
  : 'no answer in time; the question expired with its continuation owner');
@@ -1622,13 +1670,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1622
1670
  notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
1623
1671
  }
1624
1672
  }
1625
- stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
1673
+ const accepted = stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
1626
1674
  for (const answer of answered)
1627
1675
  reportAnswer(ctx, notes, answer, false);
1628
- return {
1629
- stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1630
- notes,
1631
- };
1676
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
1632
1677
  }
1633
1678
  // ---------------------------------------------------------------------------
1634
1679
  // SessionEnd — local cleanup only