@raidiant/notifai 11.6.3-beta.2 → 11.7.0-beta.2

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 (105) hide show
  1. package/CHANGELOG.md +15 -3
  2. package/dist/atomic-file.d.ts +1 -1
  3. package/dist/atomic-file.js.map +1 -1
  4. package/dist/client.d.ts +5 -1
  5. package/dist/client.js +2 -0
  6. package/dist/client.js.map +1 -1
  7. package/dist/codex-answer-control.d.ts +14 -0
  8. package/dist/codex-answer-control.js +41 -0
  9. package/dist/codex-answer-control.js.map +1 -0
  10. package/dist/codex-answer-presentation.d.ts +18 -0
  11. package/dist/codex-answer-presentation.js +113 -0
  12. package/dist/codex-answer-presentation.js.map +1 -0
  13. package/dist/codex-input-lifecycle.d.ts +10 -0
  14. package/dist/codex-input-lifecycle.js +63 -0
  15. package/dist/codex-input-lifecycle.js.map +1 -0
  16. package/dist/codex-native-control.d.ts +13 -0
  17. package/dist/codex-native-control.js +120 -0
  18. package/dist/codex-native-control.js.map +1 -0
  19. package/dist/codex-native-turn.d.ts +18 -0
  20. package/dist/codex-native-turn.js +46 -4
  21. package/dist/codex-native-turn.js.map +1 -1
  22. package/dist/codex-question-bindings.d.ts +76 -0
  23. package/dist/codex-question-bindings.js +151 -0
  24. package/dist/codex-question-bindings.js.map +1 -0
  25. package/dist/codex-queue-control.d.ts +21 -0
  26. package/dist/codex-queue-control.js +59 -0
  27. package/dist/codex-queue-control.js.map +1 -0
  28. package/dist/codex-tool-messages.js +40 -16
  29. package/dist/codex-tool-messages.js.map +1 -1
  30. package/dist/commands-acknowledge.d.ts +2 -4
  31. package/dist/commands-acknowledge.js +7 -0
  32. package/dist/commands-acknowledge.js.map +1 -1
  33. package/dist/commands-ask.js +64 -12
  34. package/dist/commands-ask.js.map +1 -1
  35. package/dist/commands-auth.d.ts +3 -1
  36. package/dist/commands-auth.js +88 -28
  37. package/dist/commands-auth.js.map +1 -1
  38. package/dist/commands-close.js +51 -3
  39. package/dist/commands-close.js.map +1 -1
  40. package/dist/commands-core.d.ts +7 -0
  41. package/dist/commands-core.js +1 -0
  42. package/dist/commands-core.js.map +1 -1
  43. package/dist/commands-hook-attend.js +50 -10
  44. package/dist/commands-hook-attend.js.map +1 -1
  45. package/dist/commands-hook-run.js +2 -1
  46. package/dist/commands-hook-run.js.map +1 -1
  47. package/dist/commands-init.d.ts +2 -0
  48. package/dist/commands-init.js +3 -3
  49. package/dist/commands-init.js.map +1 -1
  50. package/dist/commands-io.js +7 -0
  51. package/dist/commands-io.js.map +1 -1
  52. package/dist/commands-native-acknowledge.d.ts +8 -0
  53. package/dist/commands-native-acknowledge.js +114 -0
  54. package/dist/commands-native-acknowledge.js.map +1 -0
  55. package/dist/credentials.d.ts +5 -0
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/hermes-attendant.js +4 -0
  58. package/dist/hermes-attendant.js.map +1 -1
  59. package/dist/hook-acknowledgements.d.ts +1 -0
  60. package/dist/hook-acknowledgements.js +3 -1
  61. package/dist/hook-acknowledgements.js.map +1 -1
  62. package/dist/hook-lifecycle.d.ts +12 -1
  63. package/dist/hook-lifecycle.js +145 -79
  64. package/dist/hook-lifecycle.js.map +1 -1
  65. package/dist/hook-question-retirement.js +2 -1
  66. package/dist/hook-question-retirement.js.map +1 -1
  67. package/dist/hook-question-state.d.ts +6 -2
  68. package/dist/hook-question-state.js +19 -7
  69. package/dist/hook-question-state.js.map +1 -1
  70. package/dist/hook-session-state.d.ts +5 -1
  71. package/dist/hook-session-state.js +48 -14
  72. package/dist/hook-session-state.js.map +1 -1
  73. package/dist/hook-types.d.ts +23 -1
  74. package/dist/hook-types.js.map +1 -1
  75. package/dist/native-answer-operation.d.ts +70 -0
  76. package/dist/native-answer-operation.js +247 -0
  77. package/dist/native-answer-operation.js.map +1 -0
  78. package/dist/pairing-qr.d.ts +5 -0
  79. package/dist/pairing-qr.js +24 -0
  80. package/dist/pairing-qr.js.map +1 -0
  81. package/dist/pending-pairing.d.ts +1 -0
  82. package/dist/pending-pairing.js +7 -5
  83. package/dist/pending-pairing.js.map +1 -1
  84. package/dist/program.js +9 -3
  85. package/dist/program.js.map +1 -1
  86. package/dist/session-attendant-probe.d.ts +1 -1
  87. package/dist/session-attendant-probe.js +2 -1
  88. package/dist/session-attendant-probe.js.map +1 -1
  89. package/dist/session-attendant-state.d.ts +5 -0
  90. package/dist/session-attendant-state.js +29 -10
  91. package/dist/session-attendant-state.js.map +1 -1
  92. package/dist/session-delivery.d.ts +29 -0
  93. package/dist/session-delivery.js +3 -1
  94. package/dist/session-delivery.js.map +1 -1
  95. package/dist/session-input-wakes.d.ts +78 -0
  96. package/dist/session-input-wakes.js +239 -0
  97. package/dist/session-input-wakes.js.map +1 -0
  98. package/dist/session-inputs.d.ts +41 -6
  99. package/dist/session-inputs.js +199 -19
  100. package/dist/session-inputs.js.map +1 -1
  101. package/dist/skill-source/manifest.json +8 -4
  102. package/dist/skill-source/notifai/SKILL.md +30 -28
  103. package/dist/skill-source/notifai/references/harness-setup.md +33 -20
  104. package/dist/skill-source/notifai/references/native-questions.md +51 -0
  105. package/package.json +10 -3
@@ -1,4 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { confirmNativeAnswerTarget, recordConfirmedNativeAnswerTarget } from './native-answer-operation.js';
3
+ import { markCodexOrdinaryPresentation, mayRetireFromPrompt, reserveCurrentCodexQuestion } from './codex-question-bindings.js';
2
4
  import { ApiCallError, isRetryableReplyPollError } from './client.js';
3
5
  import { withFileLock } from './file-lock.js';
4
6
  import { HARNESS_CAPABILITIES } from './harnesses.js';
@@ -7,9 +9,9 @@ import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, clas
7
9
  import { clearMatchingProjectSession, writeProjectSession } from './hook-project-sessions.js';
8
10
  import { acquireClaimFile, releaseClaimFile, claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
9
11
  import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, selectedForClaimedDelivery, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
10
- import { admitQueuedQuestion, clearFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, questionSubmissionAttempted, queuedQuestionStillEligible, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
11
- import { clearSessionState, markSessionEnded, pendingList, readSessionState, readSessionIncarnation, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
12
- import { currentProcessIdentity } from './process-identity.js';
12
+ import { admitQueuedQuestion, replaceFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, questionSubmissionAttempted, queuedQuestionStillEligible, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
13
+ import { clearSessionStateUnlocked, markSessionEnded, pendingList, readSessionState, readSessionIncarnation, readSessionEndMarker, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
14
+ import { currentProcessIdentity, processIdentityLiveness } from './process-identity.js';
13
15
  import { userPromptContextOutput } from './session-activation.js';
14
16
  import { answersAlreadyWritten, beginHandOff, recordUnclaimedHandOffs, recoverDeliveryJournal, sweepDeliveryJournals, } from './session-delivery.js';
15
17
  import { WRITE_ABORTED_REASON } from './wake-support.js';
@@ -120,12 +122,13 @@ function submitQuestion(ctx, sessionId, entry, intent, notes) {
120
122
  if (receipt.request_id !== intent.request_id) {
121
123
  throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
122
124
  }
125
+ recordConfirmedNativeAnswerTarget(sessionId, ctx.env, { ...entry, request_id: receipt.request_id });
123
126
  // Wakes this session's dormant Session Attendant.
124
127
  recordSessionNotified(sessionId, ctx.env, ctx.now());
125
128
  return receipt;
126
- });
129
+ }, ctx.service_identity);
127
130
  if (attempt === null)
128
- notes.push('the question was retired before submission; not uploading it');
131
+ notes.push('the question was retired or its Approved Machine changed before submission; not uploading it');
129
132
  return attempt;
130
133
  }
131
134
  catch (err) {
@@ -326,9 +329,12 @@ function claimMarker(response) {
326
329
  return selectedForClaimedDelivery(response) ? { delivery_claim: true } : {};
327
330
  }
328
331
  /** Close several windows concurrently without confusing failure with silence. */
329
- async function finalizePendings(ctx, pending, disposition) {
332
+ async function finalizePendings(ctx, sessionId, pending, disposition) {
330
333
  return Promise.all(pending.map(async (entry) => {
331
334
  const response = await finalizeReplies(ctx, entry.request_id, disposition);
335
+ if (response !== null && response.request_id === entry.request_id) {
336
+ recordConfirmedNativeAnswerTarget(sessionId, ctx.env, entry);
337
+ }
332
338
  ctx.log?.info('hook.retirement', {
333
339
  request_id: entry.request_id,
334
340
  attempted: true,
@@ -500,7 +506,7 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
500
506
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
501
507
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
502
508
  }
503
- const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(answers, state.accepted.remaining)));
509
+ const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, answers, state.accepted.remaining)));
504
510
  if (stdout !== undefined) {
505
511
  notes.push('the journaled device answer was added to the user\'s new turn');
506
512
  const selected = unclaimedSelectedAnswers(sessionId, ctx.env, answers);
@@ -525,7 +531,7 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
525
531
  let lateAnswers = [];
526
532
  if (live.length > 0) {
527
533
  const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
528
- const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
534
+ const finalized = await finalizePendings(ctx, sessionId, answered.map((entry) => entry.pending));
529
535
  lateAnswers = answered.map((entry) => {
530
536
  const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
531
537
  const replies = response?.replies.length ? response.replies : entry.replies;
@@ -554,7 +560,7 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
554
560
  if (permanentFailure !== null)
555
561
  notes.push(permanentFailure);
556
562
  }
557
- const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state));
563
+ const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state)).filter(entry => mayRetireFromPrompt(state, entry));
558
564
  for (const entry of matched) {
559
565
  ctx.log?.info('hook.retirement', {
560
566
  request_id: entry.request_id,
@@ -569,7 +575,7 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
569
575
  // the gap after erasing the only request/collapse/device identifiers.
570
576
  const updated = updateSessionState(sessionId, ctx.env, (current) => {
571
577
  const retiring = [...(current.retiring ?? [])];
572
- const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current));
578
+ const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current)).filter(entry => mayRetireFromPrompt(current, entry));
573
579
  const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
574
580
  for (const entry of matchedNow.filter(questionSubmissionAttempted)) {
575
581
  const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
@@ -616,7 +622,7 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
616
622
  // UserPromptSubmit can add context to the turn already beginning. Keep the
617
623
  // accepted-answer journal until the agent acknowledges: if this process or
618
624
  // host dies around stdout, a later prompt or Stop safely replays it.
619
- const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext([...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
625
+ const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, [...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
620
626
  if (stdout !== undefined)
621
627
  notes.push('the late device answer was added to the user\'s new turn');
622
628
  else
@@ -646,7 +652,7 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
646
652
  }
647
653
  const delivered = readSessionState(sessionId, ctx.env).delivered_answers ?? [];
648
654
  const stdout = delivered.length > 0
649
- ? userPromptContextOutput(ctx.harness, withReminder(reminder, answersContext(delivered, pendingList(updated).length)))
655
+ ? userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, delivered, pendingList(updated).length)))
650
656
  : undefined;
651
657
  return {
652
658
  notes,
@@ -657,6 +663,16 @@ async function answerPrompt(ctx, envelope, sessionId, reminder) {
657
663
  settlementRequired: needsQuestionSettlement(updated, ctx.harness),
658
664
  };
659
665
  }
666
+ /** Legacy prompt/Stop context routes bypass the common drain. Select ordinary
667
+ * presentation durably before returning any answer bytes to those writers.
668
+ */
669
+ function prepareOrdinaryAnswersContext(ctx, sessionId, answers, remaining) {
670
+ if (ctx.harness === 'codex') {
671
+ const ids = new Set(answers.flatMap(answer => answer.pending.question_id === undefined ? [] : [answer.pending.question_id]));
672
+ updateSessionState(sessionId, ctx.env, state => markCodexOrdinaryPresentation(state, ids));
673
+ }
674
+ return answersContext(answers, remaining);
675
+ }
660
676
  function needsQuestionSettlement(state, harness) {
661
677
  return pendingList(state).some((entry) => entry.request_id === undefined || harness === 'codex');
662
678
  }
@@ -952,13 +968,16 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
952
968
  return withFileLock(`${file}.lock`, () => {
953
969
  if (sessionHasEnded(sessionId, ctx.env))
954
970
  return false;
955
- const current = readSessionState(sessionId, ctx.env);
971
+ let current = readSessionState(sessionId, ctx.env);
956
972
  if (current.accepted === undefined)
957
973
  return false;
974
+ const acceptedBeforeCommit = current.accepted;
958
975
  deliveryCommitted = true;
976
+ if (ctx.harness === 'codex')
977
+ current = markCodexOrdinaryPresentation(current, new Set(answered.flatMap(answer => answer.pending.question_id === undefined ? [] : [answer.pending.question_id])));
959
978
  writeSessionStateUnlocked(file, sessionId, {
960
979
  ...current,
961
- accepted: { ...current.accepted, delivery_committed_at: ctx.now() },
980
+ accepted: { ...acceptedBeforeCommit, delivery_committed_at: ctx.now() },
962
981
  });
963
982
  return true;
964
983
  });
@@ -1234,7 +1253,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardD
1234
1253
  await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
1235
1254
  }
1236
1255
  if (answered.length > 0) {
1237
- const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
1256
+ const finalized = await finalizePendings(ctx, sessionId, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
1238
1257
  const authoritative = answered.map((entry) => {
1239
1258
  const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
1240
1259
  const replies = response?.replies.length ? response.replies : entry.replies;
@@ -1329,6 +1348,11 @@ export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx
1329
1348
  // its own notification — one ask never stands in for another.
1330
1349
  const submitted = [];
1331
1350
  for (const entry of pendingList(readSessionState(sessionId, ctx.env)).filter((entry) => entry.request_id === undefined)) {
1351
+ if (entry.service_identity !== undefined && (entry.service_identity.machine_id !== ctx.service_identity?.machine_id ||
1352
+ entry.service_identity.base_url !== ctx.service_identity?.base_url)) {
1353
+ notes.push('the registered question belongs to a different Approved Machine; preserving it without uploading');
1354
+ continue;
1355
+ }
1332
1356
  if (!queuedQuestionStillEligible(sessionId, ctx.env, entry)) {
1333
1357
  notes.push('the question was retired before submission; not uploading it');
1334
1358
  continue;
@@ -1362,6 +1386,8 @@ export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx
1362
1386
  notes.push(prepared.error);
1363
1387
  continue;
1364
1388
  }
1389
+ if (entry.service_identity !== undefined)
1390
+ prepared.service_identity = entry.service_identity;
1365
1391
  intent = prepared;
1366
1392
  // Durable before submit. If the server commits and the response is lost,
1367
1393
  // the reserved request id still lets this owner poll and finalize the
@@ -1424,8 +1450,11 @@ export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx
1424
1450
  message: err.message,
1425
1451
  });
1426
1452
  if (entry.submission !== undefined) {
1453
+ if (readSessionState(sessionId, ctx.env).native_answer_operations?.some(op => op.question_id === entry.question_id)) {
1454
+ notes.push('the original submission was rejected; preserving its native answer operation without minting a replacement question');
1455
+ continue;
1456
+ }
1427
1457
  notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
1428
- clearFrozenSubmission(sessionId, ctx.env, entry);
1429
1458
  const reminted = await prepareQuestionSubmission(ctx, {
1430
1459
  summary: entry.summary,
1431
1460
  ...(entry.body !== undefined ? { body: entry.body } : {}),
@@ -1437,20 +1466,18 @@ export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx
1437
1466
  ownerDeadlineAt,
1438
1467
  });
1439
1468
  if ('error' in reminted) {
1440
- dropPendingQuestion(sessionId, ctx.env, entry);
1469
+ // Preserve the original intent; preparation can race an early
1470
+ // native answer and is not proof that its obligation disappeared.
1441
1471
  notes.push(reminted.error);
1442
1472
  continue;
1443
1473
  }
1474
+ if (entry.service_identity !== undefined)
1475
+ reminted.service_identity = entry.service_identity;
1476
+ if (!replaceFrozenSubmission(sessionId, ctx.env, entry, intent, reminted)) {
1477
+ notes.push('the original submission changed or has a native answer obligation; no replacement was admitted');
1478
+ continue;
1479
+ }
1444
1480
  intent = reminted;
1445
- updateSessionState(sessionId, ctx.env, (current) => {
1446
- const list = pendingList(current);
1447
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1448
- if (index < 0)
1449
- return current;
1450
- const next = [...list];
1451
- next[index] = { ...next[index], submission: reminted };
1452
- return { ...current, pending: next };
1453
- });
1454
1481
  try {
1455
1482
  if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
1456
1483
  notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
@@ -1540,8 +1567,11 @@ export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx
1540
1567
  // nobody listening.
1541
1568
  if (admissionConfirmed) {
1542
1569
  updateSessionState(sessionId, ctx.env, (current) => {
1570
+ current = confirmNativeAnswerTarget(current, live);
1543
1571
  const list = pendingList(current);
1544
- const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
1572
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined &&
1573
+ (candidate.service_identity === undefined || (candidate.service_identity.machine_id === ctx.service_identity?.machine_id &&
1574
+ candidate.service_identity.base_url === ctx.service_identity?.base_url)));
1545
1575
  if (index >= 0) {
1546
1576
  const next = [...list];
1547
1577
  next[index] = live;
@@ -1603,7 +1633,7 @@ async function escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route,
1603
1633
  if (anomalous.length > 0)
1604
1634
  notes.push('server committed an answer deadline beyond this process owner; reconciling the anomalous window');
1605
1635
  const staleLive = live.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now() || anomalous.includes(entry));
1606
- const finalizedStale = await finalizePendings(ctx, staleLive, answerCloseDisposition(ctx, route));
1636
+ const finalizedStale = await finalizePendings(ctx, sessionId, staleLive, answerCloseDisposition(ctx, route));
1607
1637
  const staleAnswers = finalizedStale
1608
1638
  .map(finalizedAnswer)
1609
1639
  .filter((entry) => entry !== null);
@@ -1705,7 +1735,7 @@ async function escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route,
1705
1735
  entry.reply_deadline_at <= waitDeadline ||
1706
1736
  waited.permanentFailures.has(entry.request_id));
1707
1737
  const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
1708
- const finalized = await finalizePendings(ctx, expired, answerCloseDisposition(ctx, route));
1738
+ const finalized = await finalizePendings(ctx, sessionId, expired, answerCloseDisposition(ctx, route));
1709
1739
  const finalAnswers = finalized
1710
1740
  .map(finalizedAnswer)
1711
1741
  .filter((entry) => entry !== null);
@@ -1750,7 +1780,7 @@ async function escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route,
1750
1780
  return { notes };
1751
1781
  }
1752
1782
  const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
1753
- const finalizedAnswered = await finalizePendings(ctx, polledAnswered, answerCloseDisposition(ctx, route));
1783
+ const finalizedAnswered = await finalizePendings(ctx, sessionId, polledAnswered, answerCloseDisposition(ctx, route));
1754
1784
  const answered = [];
1755
1785
  for (const finalized of finalizedAnswered) {
1756
1786
  const entry = finalized.pending;
@@ -1797,7 +1827,7 @@ export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccept
1797
1827
  return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
1798
1828
  // Publish cancellation before reading or clearing anything. In-flight Stop
1799
1829
  // writers use the same session lock, so none can recreate state after this.
1800
- markSessionEnded(sessionId, env, now);
1830
+ const ending = markSessionEnded(sessionId, env, now);
1801
1831
  if (envelope.cwd !== undefined)
1802
1832
  clearMatchingProjectSession(envelope.cwd, env, sessionId);
1803
1833
  const state = readSessionState(sessionId, env);
@@ -1840,47 +1870,60 @@ export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccept
1840
1870
  orphanRetirements(env, orphans, now);
1841
1871
  notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
1842
1872
  }
1843
- if ((preserveAccepted && state.accepted !== undefined) ||
1844
- (state.acknowledgement_due?.length ?? 0) > 0 ||
1845
- (state.message_acknowledgement_due?.length ?? 0) > 0) {
1846
- const preserved = { ...stateWithHistory };
1847
- if (!preserveAccepted)
1848
- delete preserved.accepted;
1849
- if (!preserveAccepted)
1850
- delete preserved.waiting_answers;
1851
- delete preserved.input_wake;
1852
- delete preserved.pending;
1853
- delete preserved.retiring;
1854
- delete preserved.acknowledgement_blocks;
1855
- if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
1856
- delete preserved.acknowledgement_due;
1873
+ return withFileLock(`${sessionStatePath(sessionId, env)}.lock`, () => {
1874
+ const marker = readSessionEndMarker(sessionId, env);
1875
+ if (marker?.stamp?.mono !== ending.stamp?.mono ||
1876
+ (readSessionIncarnation(sessionId, env)?.key ?? null) !== ending.ends) {
1877
+ return { notes, log: { outcome: 'preserved', reason: 'session-resumed-during-cleanup' } };
1878
+ }
1879
+ // A committed receipt can arrive after the end marker. Preserve the fresh
1880
+ // obligation state rather than restoring the earlier cleanup snapshot.
1881
+ const state = readSessionState(sessionId, env);
1882
+ stateWithHistory = { ...state, ...(stateWithHistory.question_history === undefined ? {} : { question_history: stateWithHistory.question_history }) };
1883
+ if ((preserveAccepted && state.accepted !== undefined) ||
1884
+ (state.acknowledgement_due?.length ?? 0) > 0 ||
1885
+ (state.message_acknowledgement_due?.length ?? 0) > 0 ||
1886
+ (state.native_answer_operations?.length ?? 0) > 0 ||
1887
+ (state.codex_question_bindings?.length ?? 0) > 0) {
1888
+ const preserved = { ...stateWithHistory };
1889
+ if (!preserveAccepted)
1890
+ delete preserved.accepted;
1891
+ if (!preserveAccepted)
1892
+ delete preserved.waiting_answers;
1893
+ delete preserved.input_wake;
1894
+ delete preserved.pending;
1895
+ delete preserved.retiring;
1896
+ delete preserved.acknowledgement_blocks;
1897
+ if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
1898
+ delete preserved.acknowledgement_due;
1899
+ }
1900
+ if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
1901
+ delete preserved.message_acknowledgement_due;
1902
+ }
1903
+ writeSessionStateUnlocked(sessionStatePath(sessionId, env), sessionId, preserved);
1904
+ notes.push(preserveAccepted && state.accepted !== undefined
1905
+ ? 'preserved an accepted device answer for this exact session to resume'
1906
+ : 'preserved required Agent Acknowledgement obligations for this exact session');
1907
+ return {
1908
+ notes,
1909
+ log: {
1910
+ outcome: preserveAccepted && state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
1911
+ queued_retirements: orphans.length,
1912
+ accepted_answers: state.accepted?.answers.length ?? 0,
1913
+ acknowledgement_due: state.acknowledgement_due?.length ?? 0,
1914
+ ...(state.message_acknowledgement_due === undefined
1915
+ ? {}
1916
+ : { message_acknowledgement_due: state.message_acknowledgement_due.length }),
1917
+ },
1918
+ };
1857
1919
  }
1858
- if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
1859
- delete preserved.message_acknowledgement_due;
1920
+ const history = stateWithHistory.question_history;
1921
+ clearSessionStateUnlocked(sessionId, env);
1922
+ if ((history?.length ?? 0) > 0) {
1923
+ writeSessionStateUnlocked(sessionStatePath(sessionId, env), sessionId, { question_history: history });
1860
1924
  }
1861
- writeSessionState(sessionId, env, preserved);
1862
- notes.push(preserveAccepted && state.accepted !== undefined
1863
- ? 'preserved an accepted device answer for this exact session to resume'
1864
- : 'preserved required Agent Acknowledgement obligations for this exact session');
1865
- return {
1866
- notes,
1867
- log: {
1868
- outcome: preserveAccepted && state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
1869
- queued_retirements: orphans.length,
1870
- accepted_answers: state.accepted?.answers.length ?? 0,
1871
- acknowledgement_due: state.acknowledgement_due?.length ?? 0,
1872
- ...(state.message_acknowledgement_due === undefined
1873
- ? {}
1874
- : { message_acknowledgement_due: state.message_acknowledgement_due.length }),
1875
- },
1876
- };
1877
- }
1878
- const history = stateWithHistory.question_history;
1879
- clearSessionState(sessionId, env);
1880
- if ((history?.length ?? 0) > 0) {
1881
- writeSessionState(sessionId, env, { question_history: history });
1882
- }
1883
- return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } };
1925
+ return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } };
1926
+ });
1884
1927
  }
1885
1928
  // ---------------------------------------------------------------------------
1886
1929
  // ask — register a question for the turn's end
@@ -1901,6 +1944,15 @@ export const MAX_PENDING_QUESTIONS = 4;
1901
1944
  * the shared resource being protected here, not the agent's turn.
1902
1945
  */
1903
1946
  export const MAX_LIVE_QUESTIONS = 10;
1947
+ export class QuestionRegistrationChanged extends Error {
1948
+ reason;
1949
+ constructor(reason) {
1950
+ super(reason === 'credential'
1951
+ ? 'The Approved Machine changed before question registration completed.'
1952
+ : 'The Agent Session ended or changed before question registration completed.');
1953
+ this.reason = reason;
1954
+ }
1955
+ }
1904
1956
  /**
1905
1957
  * A session may hold several registered questions at once: a new `ask` never
1906
1958
  * ends an earlier one. Each reaches the user as its own notification and is
@@ -1913,10 +1965,24 @@ export const MAX_LIVE_QUESTIONS = 10;
1913
1965
  * The queue is keyed on the session, not the project: several agents may be
1914
1966
  * running in one project at once, and their questions must never interfere.
1915
1967
  */
1916
- export function registerQuestion(sessionId, env, question, now = Date.now()) {
1968
+ export function registerQuestion(sessionId, env, question, now = Date.now(), nativeAdmission, guard) {
1917
1969
  let full = null;
1970
+ let stored = false;
1918
1971
  const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
1919
1972
  updateSessionState(sessionId, env, (state) => {
1973
+ // Revalidate after acquiring the lock: discovery and lock contention can
1974
+ // outlive the owner or its Approved Machine. Neither may adopt this ask.
1975
+ if (guard !== undefined) {
1976
+ if (!guard.machineStillMatches())
1977
+ throw new QuestionRegistrationChanged('credential');
1978
+ if (guard.owner_key !== undefined) {
1979
+ const owner = readSessionIncarnation(sessionId, env);
1980
+ if (owner?.key !== guard.owner_key || sessionHasEnded(sessionId, env) ||
1981
+ owner.harness_process === undefined || processIdentityLiveness(owner.harness_process) !== 'alive') {
1982
+ throw new QuestionRegistrationChanged('owner');
1983
+ }
1984
+ }
1985
+ }
1920
1986
  const pending = pendingList(state);
1921
1987
  const unasked = pending.filter((entry) => entry.request_id === undefined);
1922
1988
  if (unasked.length >= MAX_PENDING_QUESTIONS) {
@@ -1927,18 +1993,15 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
1927
1993
  full = 'live';
1928
1994
  return state;
1929
1995
  }
1930
- return {
1996
+ stored = true;
1997
+ const registered = { asked_at: now, ...question, question_id: questionId, question: question.question.slice(0, MAX_STORED_QUESTION_CHARS) };
1998
+ return reserveCurrentCodexQuestion({
1931
1999
  ...state,
1932
2000
  pending: [
1933
2001
  ...pending,
1934
- {
1935
- asked_at: now,
1936
- ...question,
1937
- question_id: questionId,
1938
- question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
1939
- },
2002
+ registered,
1940
2003
  ],
1941
- };
2004
+ }, registered, sessionId, env, nativeAdmission);
1942
2005
  });
1943
2006
  if (full === 'unasked') {
1944
2007
  throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
@@ -1948,6 +2011,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
1948
2011
  throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
1949
2012
  'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
1950
2013
  }
2014
+ // updateSessionState skips its callback when SessionEnd won the lock.
2015
+ if (!stored)
2016
+ throw new QuestionRegistrationChanged('owner');
1951
2017
  return questionId;
1952
2018
  }
1953
2019
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */