@raidiant/notifai 11.4.0-beta.2 → 11.4.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 (96) hide show
  1. package/CHANGELOG.md +9 -2
  2. package/dist/claude-wake.d.ts +11 -3
  3. package/dist/claude-wake.js +74 -67
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/client.d.ts +23 -2
  6. package/dist/client.js +11 -5
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.d.ts +12 -2
  9. package/dist/codex-wake.js +50 -13
  10. package/dist/codex-wake.js.map +1 -1
  11. package/dist/commands-acknowledge.d.ts +4 -2
  12. package/dist/commands-acknowledge.js +35 -4
  13. package/dist/commands-acknowledge.js.map +1 -1
  14. package/dist/commands-ask.js +2 -2
  15. package/dist/commands-ask.js.map +1 -1
  16. package/dist/commands-close.js +16 -4
  17. package/dist/commands-close.js.map +1 -1
  18. package/dist/commands-core.d.ts +6 -0
  19. package/dist/commands-core.js.map +1 -1
  20. package/dist/commands-doctor.js +46 -1
  21. package/dist/commands-doctor.js.map +1 -1
  22. package/dist/commands-hook-attend.d.ts +61 -0
  23. package/dist/commands-hook-attend.js +475 -0
  24. package/dist/commands-hook-attend.js.map +1 -0
  25. package/dist/commands-hook-diagnostics.js +2 -2
  26. package/dist/commands-hook-diagnostics.js.map +1 -1
  27. package/dist/commands-hook-install.js +1 -1
  28. package/dist/commands-hook-install.js.map +1 -1
  29. package/dist/commands-hook-run.js +59 -2
  30. package/dist/commands-hook-run.js.map +1 -1
  31. package/dist/commands-send.js +6 -1
  32. package/dist/commands-send.js.map +1 -1
  33. package/dist/guidance-content.js +9 -35
  34. package/dist/guidance-content.js.map +1 -1
  35. package/dist/hook-acknowledgements.d.ts +25 -5
  36. package/dist/hook-acknowledgements.js +102 -45
  37. package/dist/hook-acknowledgements.js.map +1 -1
  38. package/dist/hook-events.d.ts +26 -1
  39. package/dist/hook-events.js +38 -2
  40. package/dist/hook-events.js.map +1 -1
  41. package/dist/hook-lifecycle.d.ts +6 -0
  42. package/dist/hook-lifecycle.js +297 -26
  43. package/dist/hook-lifecycle.js.map +1 -1
  44. package/dist/hook-question-lock.d.ts +18 -0
  45. package/dist/hook-question-lock.js +80 -57
  46. package/dist/hook-question-lock.js.map +1 -1
  47. package/dist/hook-question-retirement.d.ts +10 -2
  48. package/dist/hook-question-retirement.js +21 -4
  49. package/dist/hook-question-retirement.js.map +1 -1
  50. package/dist/hook-session-state.d.ts +90 -1
  51. package/dist/hook-session-state.js +201 -9
  52. package/dist/hook-session-state.js.map +1 -1
  53. package/dist/hook-types.d.ts +50 -2
  54. package/dist/injection-render.d.ts +36 -0
  55. package/dist/injection-render.js +61 -0
  56. package/dist/injection-render.js.map +1 -0
  57. package/dist/install-hooks.d.ts +14 -0
  58. package/dist/install-hooks.js +47 -2
  59. package/dist/install-hooks.js.map +1 -1
  60. package/dist/logging.d.ts +1 -1
  61. package/dist/logging.js +12 -0
  62. package/dist/logging.js.map +1 -1
  63. package/dist/process-identity.d.ts +26 -0
  64. package/dist/process-identity.js +82 -0
  65. package/dist/process-identity.js.map +1 -0
  66. package/dist/program.js +6 -6
  67. package/dist/program.js.map +1 -1
  68. package/dist/session-attendant-probe.d.ts +74 -0
  69. package/dist/session-attendant-probe.js +131 -0
  70. package/dist/session-attendant-probe.js.map +1 -0
  71. package/dist/session-attendant-state.d.ts +53 -0
  72. package/dist/session-attendant-state.js +175 -0
  73. package/dist/session-attendant-state.js.map +1 -0
  74. package/dist/session-attendant.d.ts +135 -0
  75. package/dist/session-attendant.js +493 -0
  76. package/dist/session-attendant.js.map +1 -0
  77. package/dist/session-delivery.d.ts +214 -0
  78. package/dist/session-delivery.js +560 -0
  79. package/dist/session-delivery.js.map +1 -0
  80. package/dist/session-handoff.d.ts +98 -0
  81. package/dist/session-handoff.js +118 -0
  82. package/dist/session-handoff.js.map +1 -0
  83. package/dist/session-message-handoff.d.ts +35 -0
  84. package/dist/session-message-handoff.js +99 -0
  85. package/dist/session-message-handoff.js.map +1 -0
  86. package/dist/skill-source/manifest.json +20 -4
  87. package/dist/skill-source/notifai/SKILL.md +19 -57
  88. package/dist/skill-source/notifai/references/diagnostics.md +31 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +3 -0
  90. package/dist/skill-source/notifai/references/notes-and-edits.md +38 -0
  91. package/dist/skill-source/notifai/references/send-details.md +14 -0
  92. package/dist/skill-source/notifai/references/writing-examples.md +56 -0
  93. package/dist/wake-support.d.ts +33 -1
  94. package/dist/wake-support.js +65 -8
  95. package/dist/wake-support.js.map +1 -1
  96. package/package.json +2 -2
@@ -3,13 +3,16 @@ import { ApiCallError, isRetryableReplyPollError } from './client.js';
3
3
  import { withFileLock } from './file-lock.js';
4
4
  import { HARNESS_CAPABILITIES } from './harnesses.js';
5
5
  import { gate } from './hook-gates.js';
6
- import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, classifyJournaledAcknowledgements, finishCommittedDelivery, holdForAcknowledgement, reconcileAcknowledgementObligations, recoverQueuedAnswers, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
6
+ import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, classifyJournaledAcknowledgements, finishCommittedDelivery, holdForAcknowledgement, owedAcknowledgements, reconcileAcknowledgementObligations, recoverQueuedAnswers, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
7
7
  import { clearMatchingProjectSession, writeProjectSession } from './hook-project-sessions.js';
8
8
  import { claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
9
- import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
9
+ import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, selectedForClaimedDelivery, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
10
10
  import { admitQueuedQuestion, clearFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, questionSubmissionAttempted, queuedQuestionStillEligible, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
11
- import { clearSessionState, markSessionEnded, pendingList, readSessionState, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, } from './hook-session-state.js';
11
+ import { clearSessionState, markSessionEnded, pendingList, readSessionState, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
12
+ import { currentProcessIdentity } from './process-identity.js';
12
13
  import { userPromptContextOutput } from './session-activation.js';
14
+ import { answersAlreadyWritten, beginHandOff, recordUnclaimedHandOffs, recoverDeliveryJournal, sweepDeliveryJournals, } from './session-delivery.js';
15
+ import { WRITE_ABORTED_REASON } from './wake-support.js';
13
16
  import { QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
14
17
  import { buildDraft } from './send.js';
15
18
  /**
@@ -117,6 +120,8 @@ function submitQuestion(ctx, sessionId, entry, intent, notes) {
117
120
  if (receipt.request_id !== intent.request_id) {
118
121
  throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
119
122
  }
123
+ // Wakes this session's dormant Session Attendant.
124
+ recordSessionNotified(sessionId, ctx.env, ctx.now());
120
125
  return receipt;
121
126
  });
122
127
  if (attempt === null)
@@ -297,10 +302,27 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
297
302
  : { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) };
298
303
  }
299
304
  }
305
+ /**
306
+ * How an answer-collecting close names itself: `deliver` only when this
307
+ * waiter will claim the fenced answer before writing it in place — a Claude
308
+ * inbox write or a Codex thread-queue write while this session's attendant
309
+ * holds a lease. Otherwise no disposition, exactly as released CLIs close.
310
+ */
311
+ function answerCloseDisposition(ctx, route) {
312
+ return claimableRoute(route) && ctx.answerClaims?.lease() != null ? 'deliver' : undefined;
313
+ }
314
+ /** Routes that write into the running session itself, so a claim can cover the write. */
315
+ function claimableRoute(route) {
316
+ return route.kind === 'inbox-socket' || route.kind === 'session-queue';
317
+ }
318
+ /** The claim marker for an answer its own `deliver` close selected. */
319
+ function claimMarker(response) {
320
+ return selectedForClaimedDelivery(response) ? { delivery_claim: true } : {};
321
+ }
300
322
  /** Close several windows concurrently without confusing failure with silence. */
301
- async function finalizePendings(ctx, pending) {
323
+ async function finalizePendings(ctx, pending, disposition) {
302
324
  return Promise.all(pending.map(async (entry) => {
303
- const response = await finalizeReplies(ctx, entry.request_id);
325
+ const response = await finalizeReplies(ctx, entry.request_id, disposition);
304
326
  ctx.log?.info('hook.retirement', {
305
327
  request_id: entry.request_id,
306
328
  attempted: true,
@@ -321,6 +343,7 @@ function finalizedAnswer(finalized) {
321
343
  replies,
322
344
  agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
323
345
  agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
346
+ ...claimMarker(finalized.response),
324
347
  };
325
348
  }
326
349
  /** Persist and surface one answer without letting the two representations drift. */
@@ -373,10 +396,47 @@ function isTerminalDraftRejection(err) {
373
396
  * may be answering a different question on a device.
374
397
  */
375
398
  export async function handleUserPromptSubmit(ctx, envelope) {
376
- const notes = [];
377
399
  const sessionId = envelope.session_id;
378
400
  if (!sessionId)
379
- return { notes };
401
+ return { notes: [] };
402
+ // Owed Session Message acknowledgements come before any resumed work: a
403
+ // session resumed after SessionEnd has no Stop to remind it first.
404
+ const reminder = { text: await messageAcknowledgementReminder(ctx, sessionId), used: false };
405
+ const outcome = await answerPrompt(ctx, envelope, sessionId, reminder);
406
+ if (reminder.text === null || reminder.used || outcome.stdout !== undefined || sessionHasEnded(sessionId, ctx.env)) {
407
+ return outcome;
408
+ }
409
+ const stdout = userPromptContextOutput(ctx.harness, reminder.text);
410
+ if (stdout === undefined)
411
+ return outcome;
412
+ outcome.notes.push('an owed Session Message acknowledgement was added to the user\'s new turn');
413
+ return {
414
+ ...outcome,
415
+ stdout,
416
+ decided: false,
417
+ log: { ...outcome.log, stage: outcome.log?.['stage'] ?? 'acknowledgement-reminded' },
418
+ };
419
+ }
420
+ /** Context for the new turn, carrying the owed-acknowledgement reminder once. */
421
+ function withReminder(reminder, context) {
422
+ if (reminder.text === null)
423
+ return context;
424
+ reminder.used = true;
425
+ return `${context}\n\n${reminder.text}`;
426
+ }
427
+ /**
428
+ * The instruction for Session Message acknowledgements still owed after asking
429
+ * the service, or null when none is.
430
+ */
431
+ async function messageAcknowledgementReminder(ctx, sessionId) {
432
+ const owed = readSessionState(sessionId, ctx.env).message_acknowledgement_due ?? [];
433
+ if (owed.length === 0)
434
+ return null;
435
+ const still = await reconcileAcknowledgementObligations(ctx, sessionId, owed);
436
+ return still.length === 0 ? null : acknowledgementBlockContext(still);
437
+ }
438
+ async function answerPrompt(ctx, envelope, sessionId, reminder) {
439
+ const notes = [];
380
440
  recoverQueuedAnswers(sessionId, ctx.env);
381
441
  let state = readSessionState(sessionId, ctx.env);
382
442
  if (state.accepted !== undefined || (state.delivered_answers?.length ?? 0) > 0) {
@@ -393,6 +453,23 @@ export async function handleUserPromptSubmit(ctx, envelope) {
393
453
  state = readSessionState(sessionId, ctx.env);
394
454
  }
395
455
  }
456
+ if (state.accepted !== undefined) {
457
+ // A journaled write of this batch already began (claimed, or recorded
458
+ // after an unclaimed hand-off): adding it to this turn would hand it over
459
+ // twice. It settles instead; its acknowledgement stays owed.
460
+ const written = answersAlreadyWritten(sessionId, ctx.env, state.accepted.answers.flatMap(({ pending }) => (pending.request_id === undefined ? [] : [pending.request_id])));
461
+ if (written.length > 0) {
462
+ settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
463
+ ctx.log?.info('hook.answer', {
464
+ answered: true,
465
+ stage: 'replay-suppressed',
466
+ route: 'user-prompt-submit',
467
+ journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
468
+ });
469
+ notes.push('an earlier hand-off of the journaled answer already began its write; not adding it to this turn');
470
+ state = readSessionState(sessionId, ctx.env);
471
+ }
472
+ }
396
473
  if (state.accepted !== undefined) {
397
474
  const answers = [
398
475
  ...(state.delivered_answers ?? []),
@@ -406,13 +483,17 @@ export async function handleUserPromptSubmit(ctx, envelope) {
406
483
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
407
484
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
408
485
  }
409
- const stdout = userPromptContextOutput(ctx.harness, answersContext(answers, state.accepted.remaining));
486
+ const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(answers, state.accepted.remaining)));
410
487
  if (stdout !== undefined) {
411
488
  notes.push('the journaled device answer was added to the user\'s new turn');
489
+ const selected = unclaimedSelectedAnswers(sessionId, ctx.env, answers);
412
490
  return {
413
491
  stdout,
414
492
  decided: false,
415
493
  notes,
494
+ ...(selected.length === 0
495
+ ? {}
496
+ : { afterOutput: () => reportHandedOffAfterTheFact(ctx, sessionId, selected) }),
416
497
  log: {
417
498
  stage: 'context-added',
418
499
  route: 'user-prompt-submit',
@@ -518,7 +599,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
518
599
  // UserPromptSubmit can add context to the turn already beginning. Keep the
519
600
  // accepted-answer journal until the agent acknowledges: if this process or
520
601
  // host dies around stdout, a later prompt or Stop safely replays it.
521
- const stdout = userPromptContextOutput(ctx.harness, answersContext([...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length));
602
+ const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext([...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
522
603
  if (stdout !== undefined)
523
604
  notes.push('the late device answer was added to the user\'s new turn');
524
605
  else
@@ -541,13 +622,14 @@ export async function handleUserPromptSubmit(ctx, envelope) {
541
622
  }
542
623
  const retired = await drainRetirements(ctx, sessionId, ctx.env);
543
624
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
625
+ await sweepDeliveryJournalsFor(ctx);
544
626
  const swept = [...retired, ...orphaned];
545
627
  if (swept.length > 0) {
546
628
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
547
629
  }
548
630
  const delivered = readSessionState(sessionId, ctx.env).delivered_answers ?? [];
549
631
  const stdout = delivered.length > 0
550
- ? userPromptContextOutput(ctx.harness, answersContext(delivered, pendingList(updated).length))
632
+ ? userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(delivered, pendingList(updated).length)))
551
633
  : undefined;
552
634
  return {
553
635
  notes,
@@ -702,9 +784,9 @@ export async function runEscalationWaiter(ctx, options) {
702
784
  return held;
703
785
  state = readSessionState(sessionId, ctx.env);
704
786
  }
705
- if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0 &&
787
+ if (state.accepted === undefined && owedAcknowledgements(state).length > 0 &&
706
788
  !(options.recordStop === false && options.route.kind === 'session-queue')) {
707
- const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due);
789
+ const due = await reconcileAcknowledgementObligations(ctx, sessionId, owedAcknowledgements(state));
708
790
  const held = holdForAcknowledgement(ctx, sessionId, due, notes);
709
791
  if (held !== null)
710
792
  return held;
@@ -717,6 +799,7 @@ export async function runEscalationWaiter(ctx, options) {
717
799
  ...(await drainRetirements(ctx, sessionId, ctx.env)),
718
800
  ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
719
801
  ];
802
+ await sweepDeliveryJournalsFor(ctx);
720
803
  if (swept.length > 0) {
721
804
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
722
805
  }
@@ -785,6 +868,9 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
785
868
  notes.push('the Agent Session ended before answer delivery; stopping this observer');
786
869
  return { notes };
787
870
  }
871
+ const replayed = await suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd);
872
+ if (replayed !== null)
873
+ return replayed;
788
874
  const held = accepted.held_deliveries ?? 0;
789
875
  if (held >= MAX_HELD_DELIVERIES) {
790
876
  gate(ctx, 'held', 'delivery-limit', {
@@ -853,14 +939,42 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
853
939
  return true;
854
940
  });
855
941
  };
856
- let delivered = await route.deliver({
857
- context: answersContext(answered, remaining),
858
- answers: answered.length,
859
- remaining,
860
- request_ids: requestIds,
861
- journal_recorded_at: accepted.recorded_at,
862
- commitDelivery,
863
- });
942
+ // Answers a `deliver` close selected are claimed first, under this session's
943
+ // delivery lock, so an Answer Edit can only follow the write. A claim that
944
+ // cannot be had never withholds the answer: it is written unclaimed, as
945
+ // released CLIs write it, and its edits simply wait.
946
+ const handOff = await claimAnswerHandOff(ctx, sessionId, route, answered);
947
+ let delivered;
948
+ try {
949
+ delivered = await route.deliver({
950
+ context: answersContext(answered, remaining),
951
+ answers: answered.length,
952
+ remaining,
953
+ request_ids: requestIds,
954
+ journal_recorded_at: accepted.recorded_at,
955
+ commitDelivery: handOff === null
956
+ ? commitDelivery
957
+ : (writer) => handOff.begin(commitDelivery, { subprocess: writer === 'subprocess' }),
958
+ ...(handOff === null || handOff.claimed.length === 0
959
+ ? {}
960
+ : {
961
+ writeGuard: { writable: () => handOff.writable(), remainingMs: () => handOff.remainingMs() },
962
+ writerGroup: (pgid) => handOff.recordGroup(pgid),
963
+ }),
964
+ });
965
+ }
966
+ catch (err) {
967
+ await handOff?.finish('failed');
968
+ throw err;
969
+ }
970
+ await handOff?.finish(delivered.acknowledgement === 'delivered'
971
+ ? 'written'
972
+ : delivered.log?.['reason'] === WRITE_ABORTED_REASON
973
+ ? 'aborted'
974
+ : 'not-written');
975
+ if (delivered.acknowledgement === 'delivered' && deliveryCommitted) {
976
+ await reportUnclaimedHandOff(ctx, sessionId, answered, handOff);
977
+ }
864
978
  if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
865
979
  delivered = {
866
980
  notes: [
@@ -911,6 +1025,152 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
911
1025
  }
912
1026
  return outcome;
913
1027
  }
1028
+ /**
1029
+ * A restart must never write an answer twice. When this session's delivery
1030
+ * journal shows that an earlier hand-off of any answer in the batch already
1031
+ * began its write — completed (`written`) or possibly (`writing`, `failed`) —
1032
+ * the batch settles without another write. Journal recovery reports the
1033
+ * earlier attempt's outcome; an unconfirmed one is never retried.
1034
+ */
1035
+ async function suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd) {
1036
+ const requestIds = accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]);
1037
+ const written = answersAlreadyWritten(sessionId, ctx.env, requestIds);
1038
+ if (written.length === 0)
1039
+ return null;
1040
+ if (ctx.answerClaims !== undefined) {
1041
+ try {
1042
+ await recoverDeliveryJournal(answerSequencer(ctx, sessionId, ctx.answerClaims));
1043
+ }
1044
+ catch {
1045
+ // Reporting is recovery's to retry; suppression does not depend on it.
1046
+ }
1047
+ }
1048
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
1049
+ ctx.log?.info('hook.answer', {
1050
+ answered: true,
1051
+ stage: 'replay-suppressed',
1052
+ request_ids: requestIds,
1053
+ journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
1054
+ });
1055
+ notes.push('an earlier hand-off of this answer already began its write; not writing it again');
1056
+ return { notes, log: { stage: 'replay-suppressed', request_ids: requestIds } };
1057
+ }
1058
+ /**
1059
+ * The Answer Edit release rule needs the fenced answer's outcome. A selected
1060
+ * answer written without a claim (the lease moved, the lock stayed busy, the
1061
+ * claim was refused) is recorded after the fact as handed off, so its edits
1062
+ * follow it instead of waiting on an attempt that never existed.
1063
+ */
1064
+ async function reportUnclaimedHandOff(ctx, sessionId, answered, handOff) {
1065
+ const claimed = new Set((handOff?.claimed ?? []).flatMap((attempt) => attempt.subject.type === 'answer' ? [attempt.subject.request_id] : []));
1066
+ const unclaimed = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined && !claimed.has(pending.request_id)
1067
+ ? [pending.request_id]
1068
+ : []);
1069
+ await reportHandedOffAfterTheFact(ctx, sessionId, unclaimed);
1070
+ }
1071
+ /**
1072
+ * Journal, then record, selected answers already written without a claim. A
1073
+ * record that fails stays in this session's delivery journal, so the next
1074
+ * hand-off here or the machine-wide sweep records it later.
1075
+ */
1076
+ export async function reportHandedOffAfterTheFact(ctx, sessionId, requestIds) {
1077
+ if (requestIds.length === 0)
1078
+ return;
1079
+ const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
1080
+ if (writer === null)
1081
+ return;
1082
+ await recordUnclaimedHandOffs({
1083
+ sessionId,
1084
+ env: ctx.env,
1085
+ client: ctx.client,
1086
+ monotonic: ctx.answerClaims?.monotonic ?? (() => performance.now()),
1087
+ wall: ctx.now,
1088
+ sleep: ctx.sleep,
1089
+ writer,
1090
+ ...(ctx.log === undefined ? {} : { log: ctx.log }),
1091
+ }, requestIds);
1092
+ }
1093
+ function answerSequencer(ctx, sessionId, claims) {
1094
+ return {
1095
+ sessionId,
1096
+ env: ctx.env,
1097
+ client: ctx.client,
1098
+ monotonic: claims.monotonic,
1099
+ wall: ctx.now,
1100
+ sleep: ctx.sleep,
1101
+ writer: claims.writer,
1102
+ ...(ctx.log === undefined ? {} : { log: ctx.log }),
1103
+ };
1104
+ }
1105
+ /**
1106
+ * Selected answers this prompt hands off in its context without a claim; the
1107
+ * ones a claimed write already covered are left to that attempt.
1108
+ */
1109
+ function unclaimedSelectedAnswers(sessionId, env, answers) {
1110
+ const selected = answers.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined ? [pending.request_id] : []);
1111
+ const covered = new Set(answersAlreadyWritten(sessionId, env, selected).map((entry) => entry.requestId));
1112
+ return selected.filter((requestId) => !covered.has(requestId));
1113
+ }
1114
+ /** The machine-wide journal sweep, from a hook that already holds a client. */
1115
+ async function sweepDeliveryJournalsFor(ctx) {
1116
+ const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
1117
+ if (writer === null)
1118
+ return;
1119
+ try {
1120
+ await sweepDeliveryJournals({
1121
+ env: ctx.env,
1122
+ client: ctx.client,
1123
+ writer,
1124
+ now: ctx.now(),
1125
+ ...(ctx.log === undefined ? {} : { log: ctx.log }),
1126
+ });
1127
+ }
1128
+ catch {
1129
+ // Housekeeping never fails a hook; the next sweep retries.
1130
+ }
1131
+ }
1132
+ /** How long a waiter waits for the attendant to re-acquire a moved lease. */
1133
+ const ANSWER_CLAIM_RETRY_MS = 2_000;
1134
+ const ANSWER_CLAIM_ROUNDS = 3;
1135
+ /**
1136
+ * Claim every selected answer of this batch for one in-place write, or null
1137
+ * when nothing may be claimed: no attendant lease, a route that does not write
1138
+ * in place, or a delivery lock another writer kept.
1139
+ */
1140
+ async function claimAnswerHandOff(ctx, sessionId, route, answered) {
1141
+ const claims = ctx.answerClaims;
1142
+ const subjects = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined
1143
+ ? [{ type: 'answer', request_id: pending.request_id }]
1144
+ : []);
1145
+ if (claims === undefined || subjects.length === 0 || !claimableRoute(route))
1146
+ return null;
1147
+ const deps = answerSequencer(ctx, sessionId, claims);
1148
+ for (let round = 1;; round += 1) {
1149
+ const lease = claims.lease();
1150
+ const handOff = lease === null ? null : await beginHandOff(deps, { lease, subjects });
1151
+ if (handOff === null) {
1152
+ ctx.log?.info('delivery.claimed', {
1153
+ subject: 'answer',
1154
+ request_ids: subjects.map((subject) => subject.request_id),
1155
+ claimed: false,
1156
+ reason: lease === null ? 'no-attendant-lease' : 'delivery-lock-busy',
1157
+ });
1158
+ if (lease === null && round < ANSWER_CLAIM_ROUNDS) {
1159
+ await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
1160
+ continue;
1161
+ }
1162
+ return null;
1163
+ }
1164
+ // A lease that moved between the close and this claim is re-read once the
1165
+ // attendant has had a moment to re-acquire; any other refusal is final.
1166
+ const fenced = handOff.claimed.length === 0 &&
1167
+ handOff.refused.every((refusal) => refusal.reason === 'generation_fenced');
1168
+ if (!fenced || round >= ANSWER_CLAIM_ROUNDS)
1169
+ return handOff;
1170
+ await handOff.finish('not-written');
1171
+ await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
1172
+ }
1173
+ }
914
1174
  /** True while this Stop owner is still allowed a direct-wake wait. */
915
1175
  function ownerLeaseActive(entry, now) {
916
1176
  return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
@@ -944,7 +1204,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
944
1204
  await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
945
1205
  }
946
1206
  if (answered.length > 0) {
947
- const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
1207
+ const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
948
1208
  const authoritative = answered.map((entry) => {
949
1209
  const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
950
1210
  const replies = response?.replies.length ? response.replies : entry.replies;
@@ -959,6 +1219,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
959
1219
  entry.agent_acknowledgement_required,
960
1220
  agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
961
1221
  entry.agent_acknowledgement_text_required,
1222
+ ...claimMarker(response),
962
1223
  };
963
1224
  });
964
1225
  const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
@@ -1265,7 +1526,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1265
1526
  delete live.source;
1266
1527
  delete live.submission;
1267
1528
  if (live.reply_deadline_at > live.owner_deadline_at) {
1268
- const response = await finalizeReplies(ctx, live.request_id);
1529
+ const response = await finalizeReplies(ctx, live.request_id, answerCloseDisposition(ctx, route));
1269
1530
  if (response === null) {
1270
1531
  // Closing was unreachable, so the question is still potentially live.
1271
1532
  // Preserve it in the exact session instead of demoting it to the orphan
@@ -1300,6 +1561,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1300
1561
  replies: response.replies,
1301
1562
  agent_acknowledgement_required: response.agent_acknowledgement_required,
1302
1563
  agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
1564
+ ...claimMarker(response),
1303
1565
  });
1304
1566
  }
1305
1567
  dropPendingQuestion(sessionId, ctx.env, entry);
@@ -1354,7 +1616,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1354
1616
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1355
1617
  }
1356
1618
  const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
1357
- const finalizedStale = await finalizePendings(ctx, staleLive);
1619
+ const finalizedStale = await finalizePendings(ctx, staleLive, answerCloseDisposition(ctx, route));
1358
1620
  const staleAnswers = finalizedStale
1359
1621
  .map(finalizedAnswer)
1360
1622
  .filter((entry) => entry !== null);
@@ -1431,7 +1693,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1431
1693
  entry.reply_deadline_at <= waitDeadline ||
1432
1694
  waited.permanentFailures.has(entry.request_id));
1433
1695
  const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
1434
- const finalized = await finalizePendings(ctx, expired);
1696
+ const finalized = await finalizePendings(ctx, expired, answerCloseDisposition(ctx, route));
1435
1697
  const finalAnswers = finalized
1436
1698
  .map(finalizedAnswer)
1437
1699
  .filter((entry) => entry !== null);
@@ -1476,7 +1738,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1476
1738
  return { notes };
1477
1739
  }
1478
1740
  const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
1479
- const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
1741
+ const finalizedAnswered = await finalizePendings(ctx, polledAnswered, answerCloseDisposition(ctx, route));
1480
1742
  const answered = [];
1481
1743
  for (const finalized of finalizedAnswered) {
1482
1744
  const entry = finalized.pending;
@@ -1494,6 +1756,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1494
1756
  replies: authoritative,
1495
1757
  agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
1496
1758
  agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
1759
+ ...claimMarker(finalized.response),
1497
1760
  });
1498
1761
  if (finalized.response === null) {
1499
1762
  notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
@@ -1564,7 +1827,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1564
1827
  orphanRetirements(env, orphans, now);
1565
1828
  notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
1566
1829
  }
1567
- if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
1830
+ if (state.accepted !== undefined ||
1831
+ (state.acknowledgement_due?.length ?? 0) > 0 ||
1832
+ (state.message_acknowledgement_due?.length ?? 0) > 0) {
1568
1833
  const preserved = { ...stateWithHistory };
1569
1834
  delete preserved.pending;
1570
1835
  delete preserved.retiring;
@@ -1572,6 +1837,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1572
1837
  if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
1573
1838
  delete preserved.acknowledgement_due;
1574
1839
  }
1840
+ if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
1841
+ delete preserved.message_acknowledgement_due;
1842
+ }
1575
1843
  writeSessionState(sessionId, env, preserved);
1576
1844
  notes.push(state.accepted !== undefined
1577
1845
  ? 'preserved an accepted device answer for this exact session to resume'
@@ -1583,6 +1851,9 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1583
1851
  queued_retirements: orphans.length,
1584
1852
  accepted_answers: state.accepted?.answers.length ?? 0,
1585
1853
  acknowledgement_due: state.acknowledgement_due?.length ?? 0,
1854
+ ...(state.message_acknowledgement_due === undefined
1855
+ ? {}
1856
+ : { message_acknowledgement_due: state.message_acknowledgement_due.length }),
1586
1857
  },
1587
1858
  };
1588
1859
  }