@raidiant/notifai 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
package/dist/hooks.js CHANGED
@@ -74,6 +74,13 @@ export function waiterCeilingSeconds(detached) {
74
74
  * server go on taking an answer after the waiter that owns it has returned.
75
75
  */
76
76
  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;
77
84
  /**
78
85
  * The terminal-first wait: the question sits in the terminal for
79
86
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -140,6 +147,81 @@ function updateSessionState(sessionId, env, update) {
140
147
  return next;
141
148
  });
142
149
  }
150
+ /** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
151
+ export function markSessionActivation(sessionId, env, now, ownerPid) {
152
+ updateSessionState(sessionId, env, (current) => ({
153
+ ...current,
154
+ activation_context_emitted_at: now,
155
+ activation_context_owner_pid: ownerPid,
156
+ }));
157
+ }
158
+ /**
159
+ * Atomically own first-prompt activation only when no lifecycle hook did.
160
+ *
161
+ * Some managed hosts preserve UserPromptSubmit while regenerating away newer
162
+ * SessionStart definitions. This is a compatibility seam, not the primary
163
+ * lifecycle: one session gets at most one model-visible activation from it.
164
+ */
165
+ export function claimPromptActivation(sessionId, env, now, ownerPid) {
166
+ let claimed = false;
167
+ updateSessionState(sessionId, env, (current) => {
168
+ if (current.activation_context_emitted_at !== undefined &&
169
+ current.activation_context_owner_pid === ownerPid) {
170
+ return current;
171
+ }
172
+ claimed = true;
173
+ return {
174
+ ...current,
175
+ activation_context_emitted_at: now,
176
+ activation_context_owner_pid: ownerPid,
177
+ };
178
+ });
179
+ return claimed;
180
+ }
181
+ /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
182
+ export function claimCursorStopActivation(sessionId, env, now) {
183
+ const claimGuardMs = 30_000;
184
+ let claimed = false;
185
+ updateSessionState(sessionId, env, (current) => {
186
+ if (current.cursor_activation_confirmed_at !== undefined)
187
+ return current;
188
+ if (current.cursor_activation_claimed_at !== undefined &&
189
+ now - current.cursor_activation_claimed_at < claimGuardMs) {
190
+ return current;
191
+ }
192
+ claimed = true;
193
+ return { ...current, cursor_activation_claimed_at: now };
194
+ });
195
+ return claimed;
196
+ }
197
+ /** Cursor's loop_count proves the previous Stop follow-up reached the host. */
198
+ export function confirmCursorStopActivation(sessionId, env, now) {
199
+ let owned = false;
200
+ updateSessionState(sessionId, env, (current) => {
201
+ if (current.cursor_activation_confirmed_at !== undefined) {
202
+ owned = true;
203
+ return current;
204
+ }
205
+ if (current.cursor_activation_claimed_at === undefined)
206
+ return current;
207
+ owned = true;
208
+ return { ...current, cursor_activation_confirmed_at: now };
209
+ });
210
+ return owned;
211
+ }
212
+ /** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
213
+ export function resetCursorStopActivation(sessionId, env) {
214
+ updateSessionState(sessionId, env, (current) => {
215
+ if (current.cursor_activation_claimed_at === undefined &&
216
+ current.cursor_activation_confirmed_at === undefined) {
217
+ return current;
218
+ }
219
+ const next = { ...current };
220
+ delete next.cursor_activation_claimed_at;
221
+ delete next.cursor_activation_confirmed_at;
222
+ return next;
223
+ });
224
+ }
143
225
  /**
144
226
  * Session state a crashed harness left behind.
145
227
  *
@@ -527,7 +609,6 @@ async function prepareQuestionSubmission(ctx, options) {
527
609
  const build = buildDraft(ctx.config, {
528
610
  title: options.title,
529
611
  body: options.body,
530
- event: options.event,
531
612
  lifecycle: { tier: 'needs_you' },
532
613
  ...(options.project !== undefined ? { project: options.project } : {}),
533
614
  ...(options.media !== undefined ? { media: options.media } : {}),
@@ -573,6 +654,9 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
573
654
  let degraded = false;
574
655
  let firstPoll = true;
575
656
  const permanentFailures = new Map();
657
+ if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
658
+ return { byRequest: new Map(), degraded: false, permanentFailures };
659
+ }
576
660
  for (;;) {
577
661
  const remainingMs = Math.max(0, deadline - ctx.now());
578
662
  if (!firstPoll && remainingMs === 0) {
@@ -657,45 +741,20 @@ async function closeQuietly(ctx, requestId) {
657
741
  await finalizeReplies(ctx, requestId);
658
742
  }
659
743
  /**
660
- * Retire the question on every device it reached. A state change is not news
661
- * (D-B): the retirement rides as a background state sync — no alert, no
662
- * sound — carrying the shared collapse key so the companion removes the
663
- * delivered question and marks it done. If the app cannot run, the stale
664
- * question simply waits until next open, which beats a tombstone banner
665
- * announcing what the user just did.
744
+ * Close the reply window so the server can mint the retirement push. Client
745
+ * submissions cannot carry `question_retired`; that event is reserved for
746
+ * server-origin state syncs.
666
747
  */
667
- async function retire(ctx, collapseKey, title, body, endState, retiresRequestId, devices, project, source) {
668
- const build = buildDraft(ctx.config, {
669
- title,
670
- body,
671
- event: 'question_retired',
672
- lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
673
- ...(project !== undefined ? { project } : {}),
674
- ...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
675
- collapseKey,
676
- level: 'passive',
677
- }, source === undefined ? {} : { source });
678
- if (!build.ok)
679
- return false;
680
- try {
681
- await ctx.client.submit({ idempotency_key: `retire-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
682
- return true;
683
- }
684
- catch {
685
- // Same reasoning as closeQuietly: the window is already closed server-side.
686
- return false;
687
- }
748
+ async function proveRetirement(ctx, requestId) {
749
+ const response = await finalizeReplies(ctx, requestId);
750
+ const proven = response !== null;
751
+ ctx.log?.info('hook.retirement', {
752
+ request_id: requestId,
753
+ attempted: true,
754
+ proven,
755
+ });
756
+ return proven;
688
757
  }
689
- /**
690
- * The title a retirement carries. It is never rendered as a banner — the push
691
- * is `content-available` only — but it is what a companion with no matching
692
- * history entry has to fall back on, and it is what shows in server-side logs.
693
- */
694
- const RETIREMENT_TITLES = {
695
- answered: 'Answered',
696
- answered_elsewhere: 'Answered in the terminal',
697
- expired: 'Question expired',
698
- };
699
758
  /**
700
759
  * Park a delivered question for retirement. Called at the moment we learn it is
701
760
  * dead, which is frequently not a moment we can reach the network.
@@ -749,11 +808,36 @@ function retiringQuestion(pending, state, cwd) {
749
808
  state,
750
809
  };
751
810
  }
752
- function retirementDeviceIds(entry) {
753
- if (!Array.isArray(entry.device_ids) || entry.device_ids.length === 0) {
754
- throw new Error(`retirement for ${entry.request_id} is missing its device identifiers; refusing to re-resolve routing`);
755
- }
756
- return entry.device_ids;
811
+ const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay']);
812
+ function normalizePrompt(value) {
813
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
814
+ }
815
+ function pendingChoiceLabels(pending) {
816
+ const fromQuestions = (pending.questions ?? []).flatMap((question) => (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]));
817
+ return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0);
818
+ }
819
+ function pendingHasMatchingChoice(pending, normalizedPrompt) {
820
+ return pendingChoiceLabels(pending).includes(normalizedPrompt);
821
+ }
822
+ /**
823
+ * Which outstanding questions this prompt plausibly answers.
824
+ *
825
+ * A unique closed-choice match is enough. A short confirmation such as "done"
826
+ * matches only when exactly one question is outstanding. Long unrelated
827
+ * prompts leave every question open for the reply window.
828
+ */
829
+ export function pendingAnsweredByPrompt(prompt, pending) {
830
+ const normalized = normalizePrompt(prompt ?? '');
831
+ if (normalized.length === 0 || pending.length === 0)
832
+ return [];
833
+ const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized));
834
+ if (labelled.length === 1)
835
+ return labelled;
836
+ if (labelled.length > 1)
837
+ return [];
838
+ if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized))
839
+ return [...pending];
840
+ return [];
757
841
  }
758
842
  /**
759
843
  * Send every parked retirement, and forget the ones that landed. A failure
@@ -766,9 +850,8 @@ export async function drainRetirements(ctx, sessionId, env) {
766
850
  return [];
767
851
  const retired = [];
768
852
  for (const entry of queue) {
769
- await closeQuietly(ctx, entry.request_id);
770
- const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), entry.project, entry.source);
771
- if (sent) {
853
+ const proven = await proveRetirement(ctx, entry.request_id);
854
+ if (proven) {
772
855
  retired.push(entry.request_id);
773
856
  // Persist each success before touching the next entry. A later corrupt
774
857
  // record or interrupted process must not resurrect work already done.
@@ -837,9 +920,8 @@ export async function drainOrphanRetirements(ctx, env, now) {
837
920
  updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
838
921
  continue;
839
922
  }
840
- await closeQuietly(ctx, entry.request_id);
841
- const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), entry.project, entry.source);
842
- if (sent) {
923
+ const proven = await proveRetirement(ctx, entry.request_id);
924
+ if (proven) {
843
925
  done.push(entry.request_id);
844
926
  updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
845
927
  }
@@ -893,10 +975,15 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
893
975
  }
894
976
  /** Close several windows concurrently without confusing failure with silence. */
895
977
  async function finalizePendings(ctx, pending) {
896
- return Promise.all(pending.map(async (entry) => ({
897
- pending: entry,
898
- response: await finalizeReplies(ctx, entry.request_id),
899
- })));
978
+ return Promise.all(pending.map(async (entry) => {
979
+ const response = await finalizeReplies(ctx, entry.request_id);
980
+ ctx.log?.info('hook.retirement', {
981
+ request_id: entry.request_id,
982
+ attempted: true,
983
+ proven: response !== null,
984
+ });
985
+ return { pending: entry, response };
986
+ }));
900
987
  }
901
988
  /** Convert a fenced final reply set into the exact answer handed to the model. */
902
989
  function finalizedAnswer(finalized) {
@@ -920,7 +1007,7 @@ function reportAnswer(ctx, notes, answered, late) {
920
1007
  ...(late ? { late: true } : {}),
921
1008
  request_id: answered.pending.request_id,
922
1009
  device: answered.reply.device_name,
923
- text: answered.reply.text,
1010
+ text_chars: answered.reply.text.length,
924
1011
  });
925
1012
  notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
926
1013
  }
@@ -964,7 +1051,7 @@ function isSamePending(a, b) {
964
1051
  function isTerminalDraftRejection(err) {
965
1052
  return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
966
1053
  }
967
- function dropPendingQuestion(sessionId, env, entry) {
1054
+ export function dropPendingQuestion(sessionId, env, entry) {
968
1055
  updateSessionState(sessionId, env, (current) => {
969
1056
  const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
970
1057
  const next = { ...current };
@@ -975,6 +1062,28 @@ function dropPendingQuestion(sessionId, env, entry) {
975
1062
  return next;
976
1063
  });
977
1064
  }
1065
+ /**
1066
+ * Drop registrations that never reached a device, so a later Stop cannot push
1067
+ * them. A retirement push would be noise: there is nothing on any device to
1068
+ * withdraw. Returns the withdrawn entries in registration order.
1069
+ */
1070
+ export function withdrawUnpushedQuestions(sessionId, env) {
1071
+ let withdrawn = [];
1072
+ updateSessionState(sessionId, env, (current) => {
1073
+ const pending = pendingList(current);
1074
+ withdrawn = pending.filter((entry) => entry.request_id === undefined);
1075
+ if (withdrawn.length === 0)
1076
+ return current;
1077
+ const remaining = pending.filter((entry) => entry.request_id !== undefined);
1078
+ const next = { ...current };
1079
+ if (remaining.length > 0)
1080
+ next.pending = remaining;
1081
+ else
1082
+ delete next.pending;
1083
+ return next;
1084
+ });
1085
+ return withdrawn;
1086
+ }
978
1087
  function clearFrozenSubmission(sessionId, env, entry) {
979
1088
  updateSessionState(sessionId, env, (current) => {
980
1089
  const list = pendingList(current);
@@ -1308,10 +1417,9 @@ async function retirePendings(ctx, envelope, sessionId, entries, state) {
1308
1417
  // UserPromptSubmit — the user is at the keyboard
1309
1418
  // ---------------------------------------------------------------------------
1310
1419
  /**
1311
- * Records presence and retires anything still asking on companion devices. This is the
1312
- * "answered in the terminal" case from the original design: we cannot tell
1313
- * whether the new prompt answers the question, but we do not need to — the
1314
- * user being here is what makes the notification noise.
1420
+ * Records presence. Retires a live question only when this prompt plausibly
1421
+ * answers it. Typing in the terminal is not by itself a resolution: the User
1422
+ * may be answering a different question on a device.
1315
1423
  */
1316
1424
  export async function handleUserPromptSubmit(ctx, envelope) {
1317
1425
  const notes = [];
@@ -1365,24 +1473,41 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1365
1473
  if (permanentFailure !== null)
1366
1474
  notes.push(permanentFailure);
1367
1475
  }
1476
+ const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state));
1477
+ for (const entry of matched) {
1478
+ ctx.log?.info('hook.retirement', {
1479
+ request_id: entry.request_id,
1480
+ question_id: entry.question_id,
1481
+ reason: 'prompt-matched',
1482
+ attempted: entry.request_id !== undefined,
1483
+ proven: false,
1484
+ });
1485
+ }
1368
1486
  // Park before dropping `pending`. If the process dies between these writes,
1369
1487
  // the next hook sees both copies and dedupes them; the old order could die in
1370
1488
  // the gap after erasing the only request/collapse/device identifiers.
1371
1489
  updateSessionState(sessionId, ctx.env, (current) => {
1372
1490
  const retiring = [...(current.retiring ?? [])];
1373
- const unasked = pendingList(current).filter((entry) => entry.request_id === undefined);
1374
- for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
1491
+ const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current));
1492
+ const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
1493
+ for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
1375
1494
  const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
1376
1495
  if (retirement !== null &&
1377
1496
  !retiring.some((parked) => parked.request_id === retirement.request_id)) {
1378
1497
  retiring.push(retirement);
1379
1498
  }
1380
1499
  }
1500
+ const unasked = unmatched.filter((entry) => entry.request_id === undefined);
1501
+ const stillLive = unmatched.filter((entry) => entry.request_id !== undefined);
1502
+ const remaining = [...stillLive, ...unasked];
1381
1503
  // Start from the whole document so fields introduced by a newer CLI remain
1382
1504
  // attached while this older writer applies the prompt transition it knows.
1383
- const next = { ...current, last_prompt_at: ctx.now() };
1384
- if (unasked.length > 0)
1385
- next.pending = unasked;
1505
+ const next = {
1506
+ ...current,
1507
+ last_prompt_at: ctx.now(),
1508
+ };
1509
+ if (remaining.length > 0)
1510
+ next.pending = remaining;
1386
1511
  else
1387
1512
  delete next.pending;
1388
1513
  if (retiring.length > 0)
@@ -1661,6 +1786,10 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
1661
1786
  outcome.log = delivered.log;
1662
1787
  return outcome;
1663
1788
  }
1789
+ /** True while this Stop owner is still allowed a direct-wake wait. */
1790
+ function ownerLeaseActive(entry, now) {
1791
+ return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
1792
+ }
1664
1793
  /** The complete per-session Stop decision, after this process owns the claim. */
1665
1794
  async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
1666
1795
  const live = pending.filter((entry) => entry.request_id !== undefined);
@@ -1715,18 +1844,34 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1715
1844
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1716
1845
  }
1717
1846
  const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1718
- liveToEscalate = recoverableLive;
1847
+ // A spent owner has already used its waiter ceiling. Passing those
1848
+ // questions back into `escalate` starts a fresh ceiling, holds the
1849
+ // per-session claim for another 3300s, and is why a later `ask` never
1850
+ // reached a device after a degraded waiter (the next Stop never ran, or
1851
+ // ran as claimed-elsewhere). Late answers still ride the short poll above
1852
+ // and every later turn. Independent questions whose lease is still running
1853
+ // keep their successor wait.
1854
+ const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
1855
+ liveToEscalate = stillOwned;
1719
1856
  if (unasked.length === 0) {
1720
1857
  if (recoverableLive.length === 0)
1721
1858
  return { notes };
1722
- // A previous answer may have resumed the model while independent
1723
- // questions remain. The next Stop is their successor owner: keep waiting
1724
- // to the original absolute deadline instead of polling for four seconds
1725
- // and abandoning them.
1726
- return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt, route);
1859
+ if (stillOwned.length === 0) {
1860
+ const requestIdSummary = summarizeRequestIds(recoverableLive);
1861
+ ctx.log?.info('hook.answer', {
1862
+ answered: false,
1863
+ stage: 'queued',
1864
+ request_ids: requestIdSummary.ids,
1865
+ owner_spent: true,
1866
+ });
1867
+ 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`);
1868
+ return { notes };
1869
+ }
1870
+ return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
1727
1871
  }
1728
1872
  // Questions registered after the earlier push still owe the user their
1729
- // notification; fall through and escalate just those.
1873
+ // notification; fall through and escalate just those, plus any live
1874
+ // question whose owner lease is still running.
1730
1875
  }
1731
1876
  // A Stop answer may immediately produce a legitimate follow-up question.
1732
1877
  // Allow that new generation, but never re-run an old pending question and
@@ -1830,7 +1975,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1830
1975
  ...(entry.media !== undefined ? { media: entry.media } : {}),
1831
1976
  ...(entry.project !== undefined ? { project: entry.project } : {}),
1832
1977
  ...(eventSource !== undefined ? { source: eventSource } : {}),
1833
- event: 'agent_question',
1834
1978
  windowSeconds: replyWindowSeconds,
1835
1979
  ownerDeadlineAt,
1836
1980
  });
@@ -1909,7 +2053,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1909
2053
  ...(entry.media !== undefined ? { media: entry.media } : {}),
1910
2054
  ...(entry.project !== undefined ? { project: entry.project } : {}),
1911
2055
  ...(eventSource !== undefined ? { source: eventSource } : {}),
1912
- event: 'agent_question',
1913
2056
  windowSeconds: replyWindowSeconds,
1914
2057
  ownerDeadlineAt,
1915
2058
  });
@@ -1976,7 +2119,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1976
2119
  request_id: intent.request_id,
1977
2120
  devices: intent.device_ids.length,
1978
2121
  questions: questions.length,
1979
- text: questions[0].text,
2122
+ text_chars: questions[0].text.length,
1980
2123
  });
1981
2124
  }
1982
2125
  const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
@@ -1995,14 +2138,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1995
2138
  // stale about eight minutes later and retired, so the answer given over
1996
2139
  // lunch met a closed question.
1997
2140
  //
1998
- // Both consumers of this field already clamp to the owner's own ceiling
1999
- // (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
2000
- // `Math.min(ceilingAt, …)` when deriving the wait), so recording the
2001
- // real expiry here lengthens what the user gets without lengthening what
2002
- // this process waits.
2141
+ // The reply window is how long the server accepts an answer. This
2142
+ // process still clamps its own wait to `hardDeadlineAt`. Recording the
2143
+ // owner lease separately is what stops the *next* Stop from treating
2144
+ // the remaining window as a fresh waiter ceiling.
2003
2145
  reply_deadline_at: Number.isFinite(committedReplyDeadline)
2004
2146
  ? committedReplyDeadline
2005
2147
  : intent.owner_deadline_at,
2148
+ owner_deadline_at: intent.owner_deadline_at,
2006
2149
  };
2007
2150
  if (intent.draft.source === undefined)
2008
2151
  delete live.source;
@@ -2065,7 +2208,8 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2065
2208
  return { notes };
2066
2209
  // Phase two: one wait across everything live, old and new alike.
2067
2210
  const ownerDeadline = Math.min(ceilingAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
2068
- const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000));
2211
+ const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000;
2212
+ const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000));
2069
2213
  const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
2070
2214
  const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
2071
2215
  if (permanentFailure !== null)
@@ -2087,7 +2231,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2087
2231
  // ends still retires its questions — that is what the session-end hook is
2088
2232
  // for, and it is the honest place for it.
2089
2233
  const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
2090
- entry.reply_deadline_at <= ctx.now() ||
2234
+ entry.reply_deadline_at <= ownerDeadline ||
2091
2235
  // A permanent rejection is the server saying this question can never be
2092
2236
  // answered, whatever its window says. Leaving it open would strand a
2093
2237
  // dead question on the user's devices.
@@ -2126,9 +2270,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2126
2270
  permanent_failures: waited.permanentFailures.size,
2127
2271
  waited_seconds: timeoutSeconds,
2128
2272
  });
2129
- if (waited.permanentFailures.size === 0) {
2273
+ if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
2130
2274
  notes.push(waited.degraded
2131
- ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
2275
+ ? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
2132
2276
  : 'no answer in time; the question expired with its continuation owner');
2133
2277
  }
2134
2278
  return { notes };
@@ -2260,6 +2404,7 @@ export const MAX_LIVE_QUESTIONS = 10;
2260
2404
  */
2261
2405
  export function registerQuestion(sessionId, env, question, now = Date.now()) {
2262
2406
  let full = null;
2407
+ const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
2263
2408
  updateSessionState(sessionId, env, (state) => {
2264
2409
  const pending = pendingList(state);
2265
2410
  const unasked = pending.filter((entry) => entry.request_id === undefined);
@@ -2276,9 +2421,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
2276
2421
  pending: [
2277
2422
  ...pending,
2278
2423
  {
2279
- question_id: `q_${randomBytes(12).toString('base64url')}`,
2280
2424
  asked_at: now,
2281
2425
  ...question,
2426
+ question_id: questionId,
2282
2427
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
2283
2428
  },
2284
2429
  ],
@@ -2290,8 +2435,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
2290
2435
  }
2291
2436
  if (full === 'live') {
2292
2437
  throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
2293
- 'Retire the ones you no longer need with `notifai close <request_id>` before asking another.');
2438
+ 'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
2294
2439
  }
2440
+ return questionId;
2295
2441
  }
2296
2442
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
2297
2443
  export function parseHookInput(raw) {