@raidiant/notifai 9.2.1 → 9.3.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 (114) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/codex-wake.js +9 -0
  6. package/dist/codex-wake.js.map +1 -1
  7. package/dist/commands-core.d.ts +0 -3
  8. package/dist/commands-core.js.map +1 -1
  9. package/dist/commands-doctor.js +41 -2
  10. package/dist/commands-doctor.js.map +1 -1
  11. package/dist/commands-harness-context.d.ts +2 -2
  12. package/dist/commands-harness-context.js +1 -1
  13. package/dist/commands-hook-shape.d.ts +4 -3
  14. package/dist/commands-hook-shape.js +19 -10
  15. package/dist/commands-hook-shape.js.map +1 -1
  16. package/dist/commands-hooks.js +66 -15
  17. package/dist/commands-hooks.js.map +1 -1
  18. package/dist/commands-init.js +12 -1
  19. package/dist/commands-init.js.map +1 -1
  20. package/dist/commands-project.d.ts +4 -0
  21. package/dist/commands-project.js +41 -0
  22. package/dist/commands-project.js.map +1 -0
  23. package/dist/commands-send-support.js +1 -22
  24. package/dist/commands-send-support.js.map +1 -1
  25. package/dist/commands-send.js +11 -5
  26. package/dist/commands-send.js.map +1 -1
  27. package/dist/commands-skill.js +58 -0
  28. package/dist/commands-skill.js.map +1 -1
  29. package/dist/commands.d.ts +1 -0
  30. package/dist/commands.js +1 -0
  31. package/dist/commands.js.map +1 -1
  32. package/dist/config-schema.js +2 -2
  33. package/dist/config-schema.js.map +1 -1
  34. package/dist/config.d.ts +8 -8
  35. package/dist/config.js +6 -9
  36. package/dist/config.js.map +1 -1
  37. package/dist/guidance-content.d.ts +3 -0
  38. package/dist/guidance-content.js +20 -0
  39. package/dist/guidance-content.js.map +1 -1
  40. package/dist/guidance-render.js +3 -3
  41. package/dist/guidance-render.js.map +1 -1
  42. package/dist/harnesses.js +2 -2
  43. package/dist/harnesses.js.map +1 -1
  44. package/dist/hooks.d.ts +26 -50
  45. package/dist/hooks.js +391 -150
  46. package/dist/hooks.js.map +1 -1
  47. package/dist/install-hooks.d.ts +7 -28
  48. package/dist/install-hooks.js +18 -37
  49. package/dist/install-hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -1
  51. package/dist/invocation-context.js +2 -2
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/program.d.ts +4 -1
  54. package/dist/program.js +33 -15
  55. package/dist/program.js.map +1 -1
  56. package/dist/project-enablement.d.ts +15 -0
  57. package/dist/project-enablement.js +87 -0
  58. package/dist/project-enablement.js.map +1 -0
  59. package/dist/question-timing.d.ts +46 -0
  60. package/dist/question-timing.js +48 -0
  61. package/dist/question-timing.js.map +1 -0
  62. package/dist/readiness.d.ts +7 -0
  63. package/dist/readiness.js.map +1 -1
  64. package/dist/send.d.ts +4 -2
  65. package/dist/send.js +6 -1
  66. package/dist/send.js.map +1 -1
  67. package/dist/session-activation.d.ts +1 -1
  68. package/dist/session-activation.js +3 -4
  69. package/dist/session-activation.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/claude-wake.test.ts +1 -0
  72. package/src/claude-wake.ts +10 -0
  73. package/src/codex-wake.test.ts +1 -0
  74. package/src/codex-wake.ts +9 -0
  75. package/src/commands-core.ts +0 -3
  76. package/src/commands-doctor.ts +48 -4
  77. package/src/commands-harness-context.ts +2 -2
  78. package/src/commands-hook-shape.ts +26 -10
  79. package/src/commands-hooks.ts +62 -16
  80. package/src/commands-init.ts +11 -1
  81. package/src/commands-project.ts +47 -0
  82. package/src/commands-send-support.ts +1 -27
  83. package/src/commands-send.ts +9 -4
  84. package/src/commands-skill.test.ts +44 -0
  85. package/src/commands-skill.ts +57 -0
  86. package/src/commands.test.ts +47 -17
  87. package/src/commands.ts +5 -0
  88. package/src/config-schema.ts +2 -2
  89. package/src/config.test.ts +2 -3
  90. package/src/config.ts +12 -15
  91. package/src/guidance-content.ts +22 -0
  92. package/src/guidance-render.ts +3 -3
  93. package/src/harnesses.ts +2 -2
  94. package/src/hook-adapter.test.ts +27 -8
  95. package/src/hooks.test.ts +808 -80
  96. package/src/hooks.ts +525 -181
  97. package/src/install-hooks.test.ts +8 -12
  98. package/src/install-hooks.ts +21 -37
  99. package/src/invocation-context.test.ts +1 -1
  100. package/src/invocation-context.ts +2 -2
  101. package/src/program.test.ts +18 -0
  102. package/src/program.ts +36 -14
  103. package/src/project-enablement.test.ts +55 -0
  104. package/src/project-enablement.ts +99 -0
  105. package/src/question-timing.ts +56 -0
  106. package/src/readiness.ts +8 -1
  107. package/src/send.ts +10 -3
  108. package/src/session-activation.ts +3 -4
  109. package/src/skill.test.ts +2 -2
  110. package/dist/orca-session-title.d.ts +0 -10
  111. package/dist/orca-session-title.js +0 -99
  112. package/dist/orca-session-title.js.map +0 -1
  113. package/src/orca-session-title.test.ts +0 -95
  114. package/src/orca-session-title.ts +0 -105
package/dist/hooks.js CHANGED
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol';
5
+ import { LEGACY_QUESTION_CLAIM_TTL_SECONDS, QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
5
6
  import { ApiCallError, isRetryableReplyPollError, } from './client.js';
6
7
  import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
7
8
  import { buildDraft } from './send.js';
@@ -37,50 +38,12 @@ function summarizeRequestIds(entries) {
37
38
  const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
38
39
  return { ids, display: ids.join(', ') };
39
40
  }
40
- /**
41
- * The waiter's whole wall clock: the terminal-first timer, submission, and the
42
- * wait for an answer, from the moment the turn-end hook starts.
43
- *
44
- * One plain ceiling, deliberately not a budget carved into reserves. The
45
- * reserves existed to fit every phase inside a *held* turn; on Claude Code the
46
- * turn is not held any more, and on a host that still holds one the useful
47
- * question is only how long that host is willing to wait in total.
48
- */
49
- export const WAITER_CEILING_SECONDS = 480;
50
- /**
51
- * The same wall clock where the waiter runs in the background instead of
52
- * inside a held turn.
53
- *
54
- * On a host that holds its turn, 480 s is a real cost to the person sitting in
55
- * front of it, and the number above is right. On Claude Code the Stop hook
56
- * returns at once and the waiter is detached, so the same 480 s buys nothing
57
- * and costs a direct wake: an answer arriving at minute nine had to wait for
58
- * the session's next turn even though nothing was blocked.
59
- *
60
- * This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
61
- * because that declaration's kill is silent, and under the reply window the
62
- * question was pushed with. Past it the journal still delivers at the next
63
- * turn — the ceiling decides how long a *direct* wake stays possible, never
64
- * whether the answer survives.
65
- */
66
- export const DETACHED_WAITER_CEILING_SECONDS = 3300;
67
- /** The waiter's wall clock, which depends on whether a turn is held open for it. */
68
- export function waiterCeilingSeconds(detached) {
69
- return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS;
70
- }
71
41
  /**
72
42
  * The wire contract's shortest reply window. A question submitted with less
73
43
  * than this is rejected outright, and accepting one would in any case let the
74
44
  * server go on taking an answer after the waiter that owns it has returned.
75
45
  */
76
46
  export const MIN_REPLY_WINDOW_SECONDS = 60;
77
- /**
78
- * HTTP budget reserved for the waiter's final reply fetch and close fence.
79
- * Taken from the owner deadline rather than added after it, so the hook does
80
- * not block past its ceiling. A near-zero remainder used to produce a
81
- * misleading 0s timeout and an unproven retirement.
82
- */
83
- export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3;
84
47
  /**
85
48
  * The terminal-first wait: the question sits in the terminal for
86
49
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -89,8 +52,9 @@ export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3;
89
52
  * A plain timer, and nothing else. It once polled an idle signal so it could
90
53
  * abandon the wait the moment the user touched the keyboard — necessary while
91
54
  * the wait blocked the terminal, because a user wanting to answer locally was
92
- * otherwise locked out of their own prompt. Nothing is monopolised now, so
93
- * there is nothing to watch for and no machine this cannot run on.
55
+ * otherwise locked out of their own prompt. Presence no longer changes this
56
+ * deterministic timer; whether the harness holds or wakes is a separate route
57
+ * decision.
94
58
  *
95
59
  * The window is measured from registration, not from the turn's end: a
96
60
  * question the agent asked five minutes ago while it kept working has already
@@ -109,6 +73,33 @@ async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
109
73
  function sessionStatePath(sessionId, env) {
110
74
  return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`);
111
75
  }
76
+ /**
77
+ * Durable cancellation for observers that can outlive the harness process.
78
+ *
79
+ * Absence of session state is not enough: SessionEnd deliberately deletes it,
80
+ * and an in-flight submit/fence could otherwise recreate it afterwards. The
81
+ * marker shares the session-state lock, giving every racing writer one total
82
+ * order: either its state lands before SessionEnd and is cleaned up there, or
83
+ * it observes this marker and must not write or deliver into the ended session.
84
+ */
85
+ function sessionEndMarkerPath(sessionId, env) {
86
+ return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`);
87
+ }
88
+ function sessionHasEnded(sessionId, env) {
89
+ return existsSync(sessionEndMarkerPath(sessionId, env));
90
+ }
91
+ function markSessionEnded(sessionId, env, now) {
92
+ const stateFile = sessionStatePath(sessionId, env);
93
+ withFileLock(`${stateFile}.lock`, () => {
94
+ atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`);
95
+ });
96
+ }
97
+ function clearSessionEndMarker(sessionId, env) {
98
+ const stateFile = sessionStatePath(sessionId, env);
99
+ withFileLock(`${stateFile}.lock`, () => {
100
+ rmSync(sessionEndMarkerPath(sessionId, env), { force: true });
101
+ });
102
+ }
112
103
  export function readSessionState(sessionId, env) {
113
104
  const file = sessionStatePath(sessionId, env);
114
105
  if (!existsSync(file))
@@ -187,13 +178,21 @@ function writeSessionStateUnlocked(file, sessionId, state) {
187
178
  function updateSessionState(sessionId, env, update) {
188
179
  const file = sessionStatePath(sessionId, env);
189
180
  return withFileLock(`${file}.lock`, () => {
190
- const next = update(readSessionState(sessionId, env));
181
+ const current = readSessionState(sessionId, env);
182
+ // SessionEnd and every asynchronous writer share this lock. Once the
183
+ // durable marker exists, no observer may recreate state for that session.
184
+ if (sessionHasEnded(sessionId, env))
185
+ return current;
186
+ const next = update(current);
191
187
  writeSessionStateUnlocked(file, sessionId, next);
192
188
  return next;
193
189
  });
194
190
  }
195
191
  /** Record the current installation when an owner session starts. */
196
192
  export function recordSessionStart(sessionId, env, harness, cwd) {
193
+ // Harnesses may reuse a session id only by explicitly starting that session
194
+ // again. That lifecycle edge is the sole authority for clearing cancellation.
195
+ clearSessionEndMarker(sessionId, env);
197
196
  updateSessionState(sessionId, env, (current) => ({
198
197
  ...current,
199
198
  ...(harness === undefined ? {} : { harness }),
@@ -247,9 +246,10 @@ export function resetCursorStopActivation(sessionId, env) {
247
246
  /**
248
247
  * Session state a crashed harness left behind.
249
248
  *
250
- * `SessionEnd` removes both the marker and the session override, but a harness
251
- * that crashes or is killed never reaches it. At roughly a hundred sessions a
252
- * day that is tens of thousands of files a year, none of which anything reads.
249
+ * `SessionEnd` removes session state and leaves a short-lived cancellation
250
+ * marker; a harness that crashes or is killed never reaches it. At roughly a
251
+ * hundred sessions a day that is tens of thousands of files a year, none of
252
+ * which anything reads.
253
253
  *
254
254
  * Opportunistic rather than scheduled: hooks are the only thing that runs, so
255
255
  * a hook is where this has to live. It is rate-limited by its own stamp file
@@ -309,7 +309,7 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
309
309
  * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
310
310
  * no trustworthy PID fall back to an age limit.
311
311
  */
312
- const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
312
+ const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000;
313
313
  /**
314
314
  * How long a crashed claim *guard* blocks the next hook.
315
315
  *
@@ -321,16 +321,17 @@ const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
321
321
  */
322
322
  const CLAIM_GUARD_TTL_MS = 30_000;
323
323
  const heldClaims = new Map();
324
- export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
324
+ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace, ownerDeadlineAt) {
325
325
  const file = claimPath(sessionId, env);
326
326
  const guard = `${file}.guard`;
327
327
  const token = randomBytes(12).toString('base64url');
328
+ const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(claimQuestionIdentity);
328
329
  mkdirSync(path.dirname(file), { recursive: true });
329
330
  if (!acquireClaimGuard(guard))
330
331
  return false;
331
332
  try {
332
333
  try {
333
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
334
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`, {
334
335
  mode: 0o600,
335
336
  flag: 'wx',
336
337
  });
@@ -356,7 +357,7 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
356
357
  beforeStaleReplace?.();
357
358
  rmSync(file, { force: true });
358
359
  try {
359
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
360
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`, {
360
361
  mode: 0o600,
361
362
  flag: 'wx',
362
363
  });
@@ -372,6 +373,31 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
372
373
  rmSync(guard, { force: true });
373
374
  }
374
375
  }
376
+ function claimQuestionIdentity(entry) {
377
+ return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`;
378
+ }
379
+ /** True only when the live owner could not have snapshotted this unpushed ask. */
380
+ function claimHandoffState(sessionId, env) {
381
+ const unasked = pendingList(readSessionState(sessionId, env)).filter((entry) => entry.request_id === undefined);
382
+ if (unasked.length === 0)
383
+ return { hasNewQuestion: false };
384
+ try {
385
+ const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8'));
386
+ if (!Array.isArray(held.pending_question_ids))
387
+ return { hasNewQuestion: false };
388
+ const snapshotted = new Set(held.pending_question_ids.filter((entry) => typeof entry === 'string'));
389
+ return {
390
+ hasNewQuestion: unasked.some((entry) => !snapshotted.has(claimQuestionIdentity(entry))),
391
+ ...(typeof held.owner_deadline_at === 'number' &&
392
+ Number.isFinite(held.owner_deadline_at)
393
+ ? { ownerDeadlineAt: held.owner_deadline_at }
394
+ : {}),
395
+ };
396
+ }
397
+ catch {
398
+ return { hasNewQuestion: false };
399
+ }
400
+ }
375
401
  function processIsAlive(pid) {
376
402
  try {
377
403
  process.kill(pid, 0);
@@ -605,6 +631,8 @@ function gate(ctx, verdict, reason, data = {}) {
605
631
  * still share one wall clock, long enough not to hammer the server.
606
632
  */
607
633
  const REPLY_POLL_SECONDS = 5;
634
+ /** Avoid hot-looping while a live observer cooperatively yields its claim. */
635
+ const CLAIM_HANDOFF_POLL_MS = 1_000;
608
636
  /**
609
637
  * Push a question and block for the answer.
610
638
  *
@@ -662,6 +690,11 @@ async function submitQuestion(ctx, intent) {
662
690
  }
663
691
  return receipt;
664
692
  }
693
+ function canSubmitCompleteWindow(ctx, intent, fallbackWindowSeconds) {
694
+ const frozenWindowSeconds = intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds;
695
+ return (intent.owner_deadline_at - ctx.now() >=
696
+ (frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000);
697
+ }
665
698
  /**
666
699
  * The waiter's wait: one bounded poll across every live question at once.
667
700
  *
@@ -671,18 +704,22 @@ async function submitQuestion(ctx, intent) {
671
704
  * registered. A question whose polling is permanently rejected drops out of
672
705
  * the round rather than failing the whole wait.
673
706
  */
674
- async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
707
+ async function waitForAnyReply(ctx, requestIds, timeoutSeconds, interruption) {
675
708
  const deadline = ctx.now() + timeoutSeconds * 1000;
676
709
  let degraded = false;
677
710
  let firstPoll = true;
678
711
  const permanentFailures = new Map();
679
712
  if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
680
- return { byRequest: new Map(), degraded: false, permanentFailures };
713
+ return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null };
681
714
  }
682
715
  for (;;) {
716
+ const beforePoll = interruption?.() ?? null;
717
+ if (beforePoll !== null) {
718
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll };
719
+ }
683
720
  const remainingMs = Math.max(0, deadline - ctx.now());
684
721
  if (!firstPoll && remainingMs === 0) {
685
- return { byRequest: new Map(), degraded, permanentFailures };
722
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: null };
686
723
  }
687
724
  firstPoll = false;
688
725
  const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
@@ -713,11 +750,42 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
713
750
  if (result.replies.length > 0)
714
751
  byRequest.set(result.requestId, result.replies);
715
752
  }
753
+ const afterPoll = interruption?.() ?? null;
754
+ // SessionEnd or terminal-side retirement wins even if an in-flight poll
755
+ // happened to return an answer. A newly registered question merely asks
756
+ // this owner to yield; an answer already observed remains authoritative.
757
+ if (afterPoll === 'ownership-ended') {
758
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
759
+ }
716
760
  if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
717
- return { byRequest, degraded, permanentFailures };
761
+ return { byRequest, degraded, permanentFailures, interrupted: null };
762
+ }
763
+ if (afterPoll === 'new-question') {
764
+ return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
718
765
  }
719
766
  }
720
767
  }
768
+ /**
769
+ * Local lifecycle signal for a long-lived observer.
770
+ *
771
+ * SessionEnd and terminal-side retirement remove an owned request from pending
772
+ * state; the detached process must stop instead of recreating that session.
773
+ * A new unpushed question asks this owner to yield the session claim so the
774
+ * successor Stop can submit it promptly.
775
+ */
776
+ function waiterInterruption(sessionId, env, requestIds) {
777
+ if (sessionHasEnded(sessionId, env))
778
+ return 'ownership-ended';
779
+ const pending = pendingList(readSessionState(sessionId, env));
780
+ const owned = new Set(requestIds);
781
+ // One question can be retired from the terminal while this observer owns
782
+ // several. That is a per-question change, not whole-session cancellation;
783
+ // the caller filters the changed set after each poll and keeps the rest.
784
+ return pending.some((entry) => entry.request_id === undefined &&
785
+ (entry.submission === undefined || !owned.has(entry.submission.request_id)))
786
+ ? 'new-question'
787
+ : null;
788
+ }
721
789
  /** A non-retryable replies fault, with enough server identity to act on it. */
722
790
  function replyPollFailure(err) {
723
791
  return err instanceof ApiCallError
@@ -811,9 +879,16 @@ export function parkForRetirement(sessionId, env, pending, state) {
811
879
  * the pending record instead of silently broadening or narrowing the fanout.
812
880
  */
813
881
  function retiringQuestion(pending, state, cwd) {
814
- const hasRequest = pending.request_id !== undefined;
815
- const hasCollapse = pending.collapse_key !== undefined;
816
- const hasDevices = pending.device_ids !== undefined && pending.device_ids.length > 0;
882
+ // A submit journal is written before the first network byte. SessionEnd can
883
+ // therefore race a request that the server accepts after local cleanup. Its
884
+ // reserved request id and exact fanout are already sufficient retirement
885
+ // identity, even though the ordinary live fields have not been promoted yet.
886
+ const requestId = pending.request_id ?? pending.submission?.request_id;
887
+ const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key;
888
+ const deviceIds = pending.device_ids ?? pending.submission?.device_ids;
889
+ const hasRequest = requestId !== undefined;
890
+ const hasCollapse = collapseKey !== undefined;
891
+ const hasDevices = deviceIds !== undefined && deviceIds.length > 0;
817
892
  if (!hasRequest && !hasCollapse && !hasDevices)
818
893
  return null;
819
894
  if (!hasRequest || !hasCollapse || !hasDevices) {
@@ -821,9 +896,9 @@ function retiringQuestion(pending, state, cwd) {
821
896
  }
822
897
  const source = sourceContextAtHookEvent(pending.source, cwd);
823
898
  return {
824
- request_id: pending.request_id,
825
- collapse_key: pending.collapse_key,
826
- device_ids: [...pending.device_ids],
899
+ request_id: requestId,
900
+ collapse_key: collapseKey,
901
+ device_ids: [...deviceIds],
827
902
  question: pending.question,
828
903
  ...(pending.project !== undefined ? { project: pending.project } : {}),
829
904
  ...(source !== undefined ? { source } : {}),
@@ -1269,6 +1344,32 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
1269
1344
  function amendAcceptedAnswers(ctx, sessionId, amend) {
1270
1345
  updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
1271
1346
  }
1347
+ /**
1348
+ * Finish a route write that linearized before SessionEnd.
1349
+ *
1350
+ * Ordinary state writers stop at the ended marker. This one narrow completion
1351
+ * is allowed through because the journal's own `delivery_committed_at` proves
1352
+ * the irreversible write won the ordering first; without the matching finish,
1353
+ * a later SessionStart would replay a write that already reached the harness.
1354
+ */
1355
+ function finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute) {
1356
+ const file = sessionStatePath(sessionId, ctx.env);
1357
+ withFileLock(`${file}.lock`, () => {
1358
+ const current = readSessionState(sessionId, ctx.env);
1359
+ if (current.accepted?.recorded_at !== accepted.recorded_at ||
1360
+ current.accepted.delivery_committed_at === undefined) {
1361
+ return;
1362
+ }
1363
+ writeSessionStateUnlocked(file, sessionId, {
1364
+ ...current,
1365
+ accepted: {
1366
+ ...current.accepted,
1367
+ delivered_at: ctx.now(),
1368
+ delivered_route: deliveredRoute,
1369
+ },
1370
+ });
1371
+ });
1372
+ }
1272
1373
  export function clearAcknowledgementObligation(sessionId, env, requestId) {
1273
1374
  let cleared = false;
1274
1375
  updateSessionState(sessionId, env, (current) => {
@@ -1577,7 +1678,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1577
1678
  * `notifai ask`. Guessing from the last assistant message was the alternative
1578
1679
  * and it is not worth it: a false positive here hijacks the terminal.
1579
1680
  */
1580
- export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute()) {
1681
+ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute()) {
1581
1682
  const sessionId = envelope.session_id;
1582
1683
  if (!sessionId) {
1583
1684
  gate(ctx, 'held', 'no-session', { stage: 'queued' });
@@ -1590,6 +1691,34 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
1590
1691
  processDeadlineAt,
1591
1692
  });
1592
1693
  }
1694
+ /**
1695
+ * Acquire the per-session owner, waiting only when this Stop carries a
1696
+ * newly registered question. The live owner sees that state on its next reply
1697
+ * poll and yields; without this handoff the new question could sit unpushed for
1698
+ * the older question's complete answer window.
1699
+ */
1700
+ async function acquireQuestionOwner(ctx, sessionId, hardDeadlineAt) {
1701
+ if (sessionHasEnded(sessionId, ctx.env))
1702
+ return false;
1703
+ if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
1704
+ return true;
1705
+ while (ctx.now() < hardDeadlineAt) {
1706
+ const handoff = claimHandoffState(sessionId, ctx.env);
1707
+ if (!handoff.hasNewQuestion)
1708
+ return false;
1709
+ const holderReleaseDeadlineAt = Math.min(hardDeadlineAt, handoff.ownerDeadlineAt === undefined
1710
+ ? hardDeadlineAt
1711
+ : handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000);
1712
+ if (ctx.now() >= holderReleaseDeadlineAt)
1713
+ return false;
1714
+ await ctx.sleep(Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()));
1715
+ if (sessionHasEnded(sessionId, ctx.env))
1716
+ return false;
1717
+ if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
1718
+ return true;
1719
+ }
1720
+ return false;
1721
+ }
1593
1722
  /**
1594
1723
  * Own one registered question pipeline from its terminal-first timer through
1595
1724
  * its final delivery or retirement. Hosts differ only in the injected route.
@@ -1597,7 +1726,11 @@ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() +
1597
1726
  export async function runEscalationWaiter(ctx, options) {
1598
1727
  const notes = [];
1599
1728
  const { sessionId, envelope } = options;
1600
- const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000;
1729
+ const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000;
1730
+ if (sessionHasEnded(sessionId, ctx.env)) {
1731
+ notes.push('the Agent Session already ended; no answer observer was started');
1732
+ return { notes };
1733
+ }
1601
1734
  // This event marker is diagnostic evidence in its own right. Record it
1602
1735
  // before every early return so doctor can distinguish a working Stop route
1603
1736
  // from the prompt-only state that previously looked healthy.
@@ -1610,9 +1743,9 @@ export async function runEscalationWaiter(ctx, options) {
1610
1743
  // polling, close fencing, route delivery, and every retirement path.
1611
1744
  // Real clock, deliberately, not `ctx.now` — the claim answers whether
1612
1745
  // another process is alive right now, which an injected clock cannot know.
1613
- if (!claimQuestionPush(sessionId, ctx.env)) {
1746
+ if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
1614
1747
  gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' });
1615
- notes.push('another hook is already handling this question');
1748
+ notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner');
1616
1749
  return { notes };
1617
1750
  }
1618
1751
  let settledAnswerThisPass = false;
@@ -1704,6 +1837,7 @@ export function hookContinuationRoute() {
1704
1837
  kind: 'hook-continuation',
1705
1838
  deliver: async (event) => ({
1706
1839
  stdout: stopAnswerOutput(event.context),
1840
+ commitStdout: event.commitDelivery,
1707
1841
  notes: [],
1708
1842
  // The harness reads this stdout after the process exits, so only the
1709
1843
  // successor Stop can acknowledge it.
@@ -1722,6 +1856,10 @@ export function hookContinuationRoute() {
1722
1856
  async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cwd) {
1723
1857
  const { answers: answered, remaining } = accepted;
1724
1858
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
1859
+ if (sessionHasEnded(sessionId, ctx.env)) {
1860
+ notes.push('the Agent Session ended before answer delivery; stopping this observer');
1861
+ return { notes };
1862
+ }
1725
1863
  const held = accepted.held_deliveries ?? 0;
1726
1864
  if (held >= MAX_HELD_DELIVERIES) {
1727
1865
  gate(ctx, 'held', 'delivery-limit', {
@@ -1755,6 +1893,12 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
1755
1893
  ...current,
1756
1894
  delivery_attempts: attempt,
1757
1895
  }));
1896
+ // Linearize delivery after the journal write. If SessionEnd acquired the
1897
+ // shared state lock in between, its durable marker wins and no wake starts.
1898
+ if (sessionHasEnded(sessionId, ctx.env)) {
1899
+ notes.push('the Agent Session ended before answer delivery; stopping this observer');
1900
+ return { notes };
1901
+ }
1758
1902
  ctx.log?.info('hook.gate', {
1759
1903
  verdict: 'proceeding',
1760
1904
  reason: 'answered',
@@ -1765,21 +1909,47 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
1765
1909
  journal_recorded_at: accepted.recorded_at,
1766
1910
  delivery_attempt: attempt,
1767
1911
  });
1768
- const delivered = await route.deliver({
1912
+ let deliveryCommitted = false;
1913
+ const commitDelivery = () => {
1914
+ if (deliveryCommitted)
1915
+ return true;
1916
+ const file = sessionStatePath(sessionId, ctx.env);
1917
+ return withFileLock(`${file}.lock`, () => {
1918
+ if (sessionHasEnded(sessionId, ctx.env))
1919
+ return false;
1920
+ const current = readSessionState(sessionId, ctx.env);
1921
+ if (current.accepted === undefined)
1922
+ return false;
1923
+ deliveryCommitted = true;
1924
+ writeSessionStateUnlocked(file, sessionId, {
1925
+ ...current,
1926
+ accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
1927
+ });
1928
+ return true;
1929
+ });
1930
+ };
1931
+ let delivered = await route.deliver({
1769
1932
  context: answersContext(answered, remaining),
1770
1933
  answers: answered.length,
1771
1934
  remaining,
1772
1935
  request_ids: requestIds,
1773
1936
  journal_recorded_at: accepted.recorded_at,
1937
+ commitDelivery,
1774
1938
  });
1939
+ if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
1940
+ delivered = {
1941
+ notes: [
1942
+ ...(delivered.notes ?? []),
1943
+ `the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
1944
+ ],
1945
+ log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
1946
+ acknowledgement: 'held',
1947
+ };
1948
+ }
1775
1949
  const deliveredRoute = typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind;
1776
1950
  const deliveredStage = typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered';
1777
1951
  if (delivered.acknowledgement === 'delivered') {
1778
- amendAcceptedAnswers(ctx, sessionId, (current) => ({
1779
- ...current,
1780
- delivered_at: ctx.now(),
1781
- delivered_route: deliveredRoute,
1782
- }));
1952
+ finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute);
1783
1953
  }
1784
1954
  else if (delivered.acknowledgement === 'held') {
1785
1955
  // Give the delivery attempt back and count the hold instead. The attempt
@@ -1807,6 +1977,8 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
1807
1977
  const outcome = { notes: [...notes, ...(delivered.notes ?? [])] };
1808
1978
  if (delivered.stdout !== undefined)
1809
1979
  outcome.stdout = delivered.stdout;
1980
+ if (delivered.commitStdout !== undefined)
1981
+ outcome.commitStdout = delivered.commitStdout;
1810
1982
  if (delivered.log !== undefined)
1811
1983
  outcome.log = delivered.log;
1812
1984
  return outcome;
@@ -1869,13 +2041,10 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1869
2041
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1870
2042
  }
1871
2043
  const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1872
- // A spent owner has already used its waiter ceiling. Passing those
1873
- // questions back into `escalate` starts a fresh ceiling, holds the
1874
- // per-session claim for another 3300s, and is why a later `ask` never
1875
- // reached a device after a degraded waiter (the next Stop never ran, or
1876
- // ran as claimed-elsewhere). Late answers still ride the short poll above
1877
- // and every later turn. Independent questions whose lease is still running
1878
- // keep their successor wait.
2044
+ // A spent owner has already reached the end of the answer window it owned.
2045
+ // Passing those questions back into `escalate` starts a fresh ceiling and
2046
+ // can monopolize the per-session claim after expiry. Independent questions
2047
+ // whose lease is still running keep their successor wait.
1879
2048
  const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
1880
2049
  liveToEscalate = stillOwned;
1881
2050
  if (unasked.length === 0) {
@@ -1889,7 +2058,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1889
2058
  request_ids: requestIdSummary.ids,
1890
2059
  owner_spent: true,
1891
2060
  });
1892
- notes.push(`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still answerable; the previous waiter already used its ceiling, so a later turn will collect a late answer rather than waiting again`);
2061
+ notes.push(`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still recorded after the previous answer owner ended; a later turn will reconcile ${recoverableLive.length === 1 ? 'it' : 'them'} without starting another full-window wait`);
1893
2062
  return { notes };
1894
2063
  }
1895
2064
  return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
@@ -1947,11 +2116,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1947
2116
  // The questions still owe the user their terminal-first window before
1948
2117
  // anything reaches their devices — measured from the oldest registration,
1949
2118
  // because that is the question that has waited longest.
1950
- const startedAt = ctx.now();
1951
- const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
1952
- ? []
1953
- : [entry.reply_deadline_at]);
1954
- const ceilingAt = Math.min(hardDeadlineAt, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
2119
+ const ceilingAt = hardDeadlineAt;
1955
2120
  if (unasked.length > 0 && alreadyLive.length === 0) {
1956
2121
  const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
1957
2122
  await awaitTerminalFirstWindow(ctx, oldest, ceilingAt);
@@ -1965,24 +2130,17 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1965
2130
  // Phase one: every registered question reaches the user's devices, each as
1966
2131
  // its own notification — one ask never stands in for another.
1967
2132
  const submitted = [];
2133
+ const admissionAnswers = [];
1968
2134
  for (const entry of unasked) {
1969
- // How long the answer is accepted, and how long this owner listens for it,
1970
- // are two different spans. They used to be one: the window was whatever
1971
- // remained of the waiter's ceiling, so a question asked at a turn's end
1972
- // stopped being answerable about eight minutes later, whatever the user
1973
- // had configured.
1974
- //
1975
- // That was right when the waiter was the only way an answer came back. It
1976
- // is not any more: a late answer is found by the next turn's poll or
1977
- // replayed from the journal, so this owner giving up listening says
1978
- // nothing about whether the user's answer is still wanted.
2135
+ // The service owns how long the answer is accepted. This process begins
2136
+ // before submission, so its larger maximum-window budget includes startup
2137
+ // headroom and remains alive through the complete committed window.
1979
2138
  const replyWindowSeconds = ctx.config.reply_window_seconds.value;
1980
- if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
1981
- notes.push('too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal');
2139
+ if (ceilingAt - ctx.now() <
2140
+ (replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000) {
2141
+ notes.push('too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner');
1982
2142
  continue;
1983
2143
  }
1984
- // The owner still stops listening at its own ceiling. Only the span the
1985
- // server accepts answers into changed.
1986
2144
  const ownerDeadlineAt = ceilingAt;
1987
2145
  const questions = pendingQuestions(entry);
1988
2146
  const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
@@ -2039,12 +2197,15 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2039
2197
  });
2040
2198
  }
2041
2199
  if (ctx.now() >= intent.owner_deadline_at) {
2042
- notes.push('the waiter ceiling ended before submission; preserving the frozen intent');
2200
+ notes.push('the owner lifetime ended before submission; preserving the frozen intent');
2201
+ continue;
2202
+ }
2203
+ if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
2204
+ notes.push('setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely');
2043
2205
  continue;
2044
2206
  }
2045
- const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000);
2046
- if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
2047
- notes.push('device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts');
2207
+ if (sessionHasEnded(sessionId, ctx.env)) {
2208
+ notes.push('the Agent Session ended before submission; preserving no live observer');
2048
2209
  continue;
2049
2210
  }
2050
2211
  let receipt;
@@ -2097,6 +2258,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2097
2258
  return { ...current, pending: next };
2098
2259
  });
2099
2260
  try {
2261
+ if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
2262
+ notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
2263
+ continue;
2264
+ }
2265
+ if (sessionHasEnded(sessionId, ctx.env)) {
2266
+ notes.push('the Agent Session ended before recovered submission');
2267
+ continue;
2268
+ }
2100
2269
  receipt = await submitQuestion(ctx, intent);
2101
2270
  admissionConfirmed = true;
2102
2271
  }
@@ -2156,17 +2325,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2156
2325
  request_id: intent.request_id,
2157
2326
  collapse_key: intent.collapse_key,
2158
2327
  device_ids: intent.device_ids,
2159
- // When the server stops accepting an answer — not when this owner stops
2160
- // waiting for one. Those were the same number until the window became a
2161
- // setting, and taking the minimum silently capped every question at the
2162
- // waiter's ceiling: a question asked with a day-long window was swept as
2163
- // stale about eight minutes later and retired, so the answer given over
2164
- // lunch met a closed question.
2165
- //
2166
- // The reply window is how long the server accepts an answer. This
2167
- // process still clamps its own wait to `hardDeadlineAt`. Recording the
2168
- // owner lease separately is what stops the *next* Stop from treating
2169
- // the remaining window as a fresh waiter ceiling.
2328
+ // The server's committed answer deadline is authoritative. The local
2329
+ // owner deadline starts earlier and includes startup headroom, so it may
2330
+ // be later but must never be earlier.
2170
2331
  reply_deadline_at: Number.isFinite(committedReplyDeadline)
2171
2332
  ? committedReplyDeadline
2172
2333
  : intent.owner_deadline_at,
@@ -2175,7 +2336,48 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2175
2336
  if (intent.draft.source === undefined)
2176
2337
  delete live.source;
2177
2338
  delete live.submission;
2178
- submitted.push(live);
2339
+ if (live.reply_deadline_at > live.owner_deadline_at) {
2340
+ const response = await finalizeReplies(ctx, live.request_id);
2341
+ if (response === null) {
2342
+ // Closing was unreachable, so the question is still potentially live.
2343
+ // Preserve it in the exact session instead of demoting it to the orphan
2344
+ // retirement queue, whose later close has no route for an answer.
2345
+ updateSessionState(sessionId, ctx.env, (current) => {
2346
+ const list = pendingList(current);
2347
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
2348
+ if (index < 0) {
2349
+ // Terminal input removed this registration while the anomalous
2350
+ // close was in flight. Preserve retirement identity exactly as
2351
+ // normal submit promotion does; the observer must not become the
2352
+ // only remaining record of a live card.
2353
+ const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
2354
+ const retiring = [...(current.retiring ?? [])];
2355
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
2356
+ retiring.push(retirement);
2357
+ }
2358
+ return { ...current, retiring };
2359
+ }
2360
+ const next = [...list];
2361
+ next[index] = live;
2362
+ return { ...current, pending: next };
2363
+ });
2364
+ submitted.push(live);
2365
+ notes.push('the server committed an answer deadline beyond this process owner and immediate closure was unreachable; preserving the live question for exact-session recovery');
2366
+ continue;
2367
+ }
2368
+ else if (response.replies.length > 0) {
2369
+ admissionAnswers.push({
2370
+ pending: live,
2371
+ reply: response.replies.at(-1),
2372
+ replies: response.replies,
2373
+ agent_acknowledgement_required: response.agent_acknowledgement_required,
2374
+ agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
2375
+ });
2376
+ }
2377
+ dropPendingQuestion(sessionId, ctx.env, entry);
2378
+ notes.push('the server committed an answer deadline beyond this process owner; closed the anomalous window instead of abandoning it early');
2379
+ continue;
2380
+ }
2179
2381
  // Record what is now live on the user's devices BEFORE any wait. If we
2180
2382
  // only learned these ids afterwards, a question that timed out would
2181
2383
  // leave no trace, and the user returning to the terminal could never
@@ -2201,6 +2403,21 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2201
2403
  return { ...current, retiring };
2202
2404
  });
2203
2405
  }
2406
+ if (sessionHasEnded(sessionId, ctx.env)) {
2407
+ // SessionEnd may have raced either the submit or promotion. Its snapshot
2408
+ // usually already queued the frozen intent; this idempotent add closes
2409
+ // the other ordering where the request committed just after cleanup.
2410
+ orphanRetirements(ctx.env, [retiringQuestion(live, 'expired', envelope.cwd)], ctx.now());
2411
+ notes.push('the Agent Session ended during submission; queued the question for retirement');
2412
+ continue;
2413
+ }
2414
+ submitted.push(live);
2415
+ }
2416
+ if (admissionAnswers.length > 0) {
2417
+ const accepted = stageAcceptedAnswers(ctx, sessionId, admissionAnswers, pendingList(readSessionState(sessionId, ctx.env)).length);
2418
+ for (const answer of admissionAnswers)
2419
+ reportAnswer(ctx, notes, answer, false);
2420
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
2204
2421
  }
2205
2422
  const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
2206
2423
  const finalizedStale = await finalizePendings(ctx, staleLive);
@@ -2231,40 +2448,48 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2231
2448
  ];
2232
2449
  if (waitingOn.length === 0)
2233
2450
  return { notes };
2234
- // Phase two: one wait across everything live, old and new alike.
2235
- const ownerDeadline = Math.min(ceilingAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
2236
- const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000;
2237
- const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000));
2238
- const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
2239
- const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
2240
- if (permanentFailure !== null)
2241
- notes.push(permanentFailure);
2242
- if (waited.byRequest.size === 0) {
2243
- // This owner is done listening. That used to mean the question was over:
2244
- // it closed the window server-side and retired the record, on the reasoning
2245
- // that no later answer could reach the same agent turn.
2246
- //
2247
- // The reaching was never the point — the *accepting* is. A question whose
2248
- // window is still open is polled again by the next prompt and the next
2249
- // Stop, and an answer found there is journaled and replayed. Closing here
2250
- // capped every question at this owner's ceiling however long a window the
2251
- // user had configured, which is what made a day-long window mean eight
2252
- // minutes.
2253
- //
2254
- // So: finalize only what the server has genuinely stopped accepting, and
2255
- // leave the rest live for the turn that comes next. A session that truly
2256
- // ends still retires its questions — that is what the session-end hook is
2257
- // for, and it is the honest place for it.
2258
- const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
2259
- entry.reply_deadline_at <= ownerDeadline ||
2260
- // A permanent rejection is the server saying this question can never be
2261
- // answered, whatever its window says. Leaving it open would strand a
2262
- // dead question on the user's devices.
2263
- waited.permanentFailures.has(entry.request_id));
2264
- const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry));
2265
- if (stillAnswerable.length > 0) {
2266
- notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`);
2451
+ // Phase two: keep the same owner alive across every committed answer window.
2452
+ // Different questions may expire at different times, so finalize each one at
2453
+ // its own server deadline and continue waiting on the rest. Reserving time by
2454
+ // closing early would make the advertised reply window untrue.
2455
+ let activeWaiting = waitingOn;
2456
+ let timeoutSeconds = 0;
2457
+ let waited;
2458
+ for (;;) {
2459
+ const nextReplyDeadline = Math.min(...activeWaiting.map((entry) => entry.reply_deadline_at));
2460
+ const waitDeadline = Math.min(ceilingAt, nextReplyDeadline);
2461
+ timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000));
2462
+ waited = await waitForAnyReply(ctx, activeWaiting.map((entry) => entry.request_id), timeoutSeconds, () => waiterInterruption(sessionId, ctx.env, activeWaiting.map((entry) => entry.request_id)));
2463
+ if (waited.interrupted !== null) {
2464
+ notes.push(waited.interrupted === 'new-question'
2465
+ ? 'yielding the answer owner so a newly registered question can be sent now'
2466
+ : 'answer ownership ended locally; stopping this observer without delivering into a closed session');
2467
+ return { notes };
2468
+ }
2469
+ const currentRequestIds = new Set(pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) => [entry.request_id, entry.submission?.request_id].filter((requestId) => requestId !== undefined)));
2470
+ const removedDuringPoll = activeWaiting.filter((entry) => !currentRequestIds.has(entry.request_id));
2471
+ for (const entry of removedDuringPoll) {
2472
+ waited.byRequest.delete(entry.request_id);
2473
+ waited.permanentFailures.delete(entry.request_id);
2267
2474
  }
2475
+ if (removedDuringPoll.length > 0) {
2476
+ notes.push(`${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`);
2477
+ activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id));
2478
+ if (activeWaiting.length === 0)
2479
+ return { notes };
2480
+ }
2481
+ const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
2482
+ if (permanentFailure !== null)
2483
+ notes.push(permanentFailure);
2484
+ if (waited.byRequest.size > 0)
2485
+ break;
2486
+ // A permanent rejection is authoritative immediately. Time-based expiry is
2487
+ // authoritative only when this owner reached that question's committed
2488
+ // server deadline; reaching the process ceiling first preserves the record.
2489
+ const expired = activeWaiting.filter((entry) => entry.reply_deadline_at === undefined ||
2490
+ entry.reply_deadline_at <= waitDeadline ||
2491
+ waited.permanentFailures.has(entry.request_id));
2492
+ const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
2268
2493
  const finalized = await finalizePendings(ctx, expired);
2269
2494
  const finalAnswers = finalized
2270
2495
  .map(finalizedAnswer)
@@ -2286,6 +2511,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2286
2511
  reportAnswer(ctx, notes, answer, false);
2287
2512
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
2288
2513
  }
2514
+ if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
2515
+ activeWaiting = stillAnswerable;
2516
+ continue;
2517
+ }
2289
2518
  const requestIdSummary = summarizeRequestIds(waitingOn);
2290
2519
  ctx.log?.info('hook.answer', {
2291
2520
  answered: false,
@@ -2295,14 +2524,17 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2295
2524
  permanent_failures: waited.permanentFailures.size,
2296
2525
  waited_seconds: timeoutSeconds,
2297
2526
  });
2298
- if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
2527
+ if (stillAnswerable.length > 0) {
2528
+ notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`);
2529
+ }
2530
+ else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
2299
2531
  notes.push(waited.degraded
2300
- ? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
2532
+ ? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
2301
2533
  : 'no answer in time; the question expired with its continuation owner');
2302
2534
  }
2303
2535
  return { notes };
2304
2536
  }
2305
- const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id));
2537
+ const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
2306
2538
  const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
2307
2539
  const answered = [];
2308
2540
  for (const finalized of finalizedAnswered) {
@@ -2326,7 +2558,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2326
2558
  notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
2327
2559
  }
2328
2560
  }
2329
- const accepted = stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
2561
+ const accepted = stageAcceptedAnswers(ctx, sessionId, answered, Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - answered.length));
2330
2562
  for (const answer of answered)
2331
2563
  reportAnswer(ctx, notes, answer, false);
2332
2564
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
@@ -2347,15 +2579,24 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
2347
2579
  const sessionId = envelope.session_id;
2348
2580
  if (!sessionId)
2349
2581
  return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
2582
+ // Publish cancellation before reading or clearing anything. In-flight Stop
2583
+ // writers use the same session lock, so none can recreate state after this.
2584
+ markSessionEnded(sessionId, env, now);
2350
2585
  if (envelope.cwd !== undefined)
2351
2586
  clearMatchingProjectSession(envelope.cwd, env, sessionId);
2352
2587
  const state = readSessionState(sessionId, env);
2353
2588
  const orphans = [...(state.retiring ?? [])];
2354
- for (const entry of pendingList(state)) {
2589
+ const retirementCandidates = [
2590
+ ...pendingList(state),
2591
+ ...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
2592
+ ];
2593
+ for (const entry of retirementCandidates) {
2355
2594
  try {
2356
2595
  const orphan = retiringQuestion(entry, 'expired', envelope.cwd);
2357
- if (orphan !== null)
2596
+ if (orphan !== null &&
2597
+ !orphans.some((candidate) => candidate.request_id === orphan.request_id)) {
2358
2598
  orphans.push(orphan);
2599
+ }
2359
2600
  }
2360
2601
  catch (err) {
2361
2602
  notes.push(err instanceof Error ? err.message : String(err));