@raidiant/notifai 8.0.0 → 9.1.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 (168) hide show
  1. package/CHANGELOG.md +48 -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/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. 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
@@ -108,7 +115,11 @@ export function readSessionState(sessionId, env) {
108
115
  return {};
109
116
  try {
110
117
  const parsed = JSON.parse(readFileSync(file, 'utf8'));
111
- return typeof parsed === 'object' && parsed !== null ? parsed : {};
118
+ if (typeof parsed !== 'object' || parsed === null)
119
+ return {};
120
+ const state = { ...parsed };
121
+ delete state.session_id;
122
+ return state;
112
123
  }
113
124
  catch {
114
125
  // A corrupt marker must not wedge the harness; treat it as "no evidence",
@@ -116,9 +127,50 @@ export function readSessionState(sessionId, env) {
116
127
  return {};
117
128
  }
118
129
  }
130
+ /**
131
+ * Resolve a stable question or request id to the one exact session that owns it.
132
+ *
133
+ * This deliberately searches lifecycle state, not checkout pointers: question
134
+ * identity is global on this machine, while a linked worktree is only where a
135
+ * command happened to run. Ambiguous or corrupt matches fail closed.
136
+ */
137
+ export function findOwningSession(id, env) {
138
+ const directory = path.join(stateDir(env), 'sessions');
139
+ if (!existsSync(directory))
140
+ return { sessionId: null, ambiguous: false };
141
+ const matches = [];
142
+ for (const name of readdirSync(directory)) {
143
+ if (!name.endsWith('.json'))
144
+ continue;
145
+ let persisted = null;
146
+ try {
147
+ const parsed = JSON.parse(readFileSync(path.join(directory, name), 'utf8'));
148
+ if (typeof parsed === 'object' && parsed !== null) {
149
+ persisted = parsed;
150
+ }
151
+ }
152
+ catch {
153
+ // Corrupt lifecycle state is not identity evidence.
154
+ }
155
+ if (persisted === null || typeof persisted.session_id !== 'string')
156
+ continue;
157
+ const sessionId = persisted.session_id;
158
+ const state = persisted;
159
+ const pendingMatch = pendingList(state).some((entry) => entry.question_id === id || entry.request_id === id);
160
+ const retiringMatch = (state.retiring ?? []).some((entry) => entry.request_id === id);
161
+ const acknowledgementMatch = (state.acknowledgement_due ?? []).some((entry) => entry.request_id === id);
162
+ const acceptedMatch = state.accepted?.answers.some(({ pending }) => pending.question_id === id || pending.request_id === id) ?? false;
163
+ if (pendingMatch || retiringMatch || acknowledgementMatch || acceptedMatch) {
164
+ matches.push(sessionId);
165
+ if (matches.length > 1)
166
+ return { sessionId: null, ambiguous: true };
167
+ }
168
+ }
169
+ return { sessionId: matches[0] ?? null, ambiguous: false };
170
+ }
119
171
  export function writeSessionState(sessionId, env, state) {
120
172
  const file = sessionStatePath(sessionId, env);
121
- withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state));
173
+ withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state));
122
174
  }
123
175
  export function clearSessionState(sessionId, env) {
124
176
  const file = sessionStatePath(sessionId, env);
@@ -129,14 +181,93 @@ export function clearSessionState(sessionId, env) {
129
181
  rmSync(sessionConfigPath(sessionId, env), { force: true });
130
182
  });
131
183
  }
132
- function writeSessionStateUnlocked(file, state) {
133
- atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
184
+ function writeSessionStateUnlocked(file, sessionId, state) {
185
+ atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`);
134
186
  }
135
187
  function updateSessionState(sessionId, env, update) {
136
188
  const file = sessionStatePath(sessionId, env);
137
189
  return withFileLock(`${file}.lock`, () => {
138
190
  const next = update(readSessionState(sessionId, env));
139
- writeSessionStateUnlocked(file, next);
191
+ writeSessionStateUnlocked(file, sessionId, next);
192
+ return next;
193
+ });
194
+ }
195
+ /** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
196
+ export function markSessionActivation(sessionId, env, now, ownerPid, harness, cwd) {
197
+ updateSessionState(sessionId, env, (current) => ({
198
+ ...current,
199
+ ...(harness === undefined ? {} : { harness }),
200
+ ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
201
+ activation_context_emitted_at: now,
202
+ activation_context_owner_pid: ownerPid,
203
+ }));
204
+ }
205
+ /**
206
+ * Atomically own first-prompt activation only when no lifecycle hook did.
207
+ *
208
+ * Some managed hosts preserve UserPromptSubmit while regenerating away newer
209
+ * SessionStart definitions. This is a compatibility seam, not the primary
210
+ * lifecycle: one session gets at most one model-visible activation from it.
211
+ */
212
+ export function claimPromptActivation(sessionId, env, now, ownerPid, harness, cwd) {
213
+ let claimed = false;
214
+ updateSessionState(sessionId, env, (current) => {
215
+ if (current.activation_context_emitted_at !== undefined &&
216
+ current.activation_context_owner_pid === ownerPid) {
217
+ return current;
218
+ }
219
+ claimed = true;
220
+ return {
221
+ ...current,
222
+ ...(harness === undefined ? {} : { harness }),
223
+ ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
224
+ activation_context_emitted_at: now,
225
+ activation_context_owner_pid: ownerPid,
226
+ };
227
+ });
228
+ return claimed;
229
+ }
230
+ /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
231
+ export function claimCursorStopActivation(sessionId, env, now) {
232
+ const claimGuardMs = 30_000;
233
+ let claimed = false;
234
+ updateSessionState(sessionId, env, (current) => {
235
+ if (current.cursor_activation_confirmed_at !== undefined)
236
+ return current;
237
+ if (current.cursor_activation_claimed_at !== undefined &&
238
+ now - current.cursor_activation_claimed_at < claimGuardMs) {
239
+ return current;
240
+ }
241
+ claimed = true;
242
+ return { ...current, cursor_activation_claimed_at: now };
243
+ });
244
+ return claimed;
245
+ }
246
+ /** Cursor's loop_count proves the previous Stop follow-up reached the host. */
247
+ export function confirmCursorStopActivation(sessionId, env, now) {
248
+ let owned = false;
249
+ updateSessionState(sessionId, env, (current) => {
250
+ if (current.cursor_activation_confirmed_at !== undefined) {
251
+ owned = true;
252
+ return current;
253
+ }
254
+ if (current.cursor_activation_claimed_at === undefined)
255
+ return current;
256
+ owned = true;
257
+ return { ...current, cursor_activation_confirmed_at: now };
258
+ });
259
+ return owned;
260
+ }
261
+ /** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
262
+ export function resetCursorStopActivation(sessionId, env) {
263
+ updateSessionState(sessionId, env, (current) => {
264
+ if (current.cursor_activation_claimed_at === undefined &&
265
+ current.cursor_activation_confirmed_at === undefined) {
266
+ return current;
267
+ }
268
+ const next = { ...current };
269
+ delete next.cursor_activation_claimed_at;
270
+ delete next.cursor_activation_confirmed_at;
140
271
  return next;
141
272
  });
142
273
  }
@@ -527,7 +658,6 @@ async function prepareQuestionSubmission(ctx, options) {
527
658
  const build = buildDraft(ctx.config, {
528
659
  title: options.title,
529
660
  body: options.body,
530
- event: options.event,
531
661
  lifecycle: { tier: 'needs_you' },
532
662
  ...(options.project !== undefined ? { project: options.project } : {}),
533
663
  ...(options.media !== undefined ? { media: options.media } : {}),
@@ -573,6 +703,9 @@ async function waitForAnyReply(ctx, requestIds, timeoutSeconds) {
573
703
  let degraded = false;
574
704
  let firstPoll = true;
575
705
  const permanentFailures = new Map();
706
+ if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
707
+ return { byRequest: new Map(), degraded: false, permanentFailures };
708
+ }
576
709
  for (;;) {
577
710
  const remainingMs = Math.max(0, deadline - ctx.now());
578
711
  if (!firstPoll && remainingMs === 0) {
@@ -657,45 +790,20 @@ async function closeQuietly(ctx, requestId) {
657
790
  await finalizeReplies(ctx, requestId);
658
791
  }
659
792
  /**
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.
793
+ * Close the reply window so the server can mint the retirement push. Client
794
+ * submissions cannot carry `question_retired`; that event is reserved for
795
+ * server-origin state syncs.
666
796
  */
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
- }
797
+ async function proveRetirement(ctx, requestId) {
798
+ const response = await finalizeReplies(ctx, requestId);
799
+ const proven = response !== null;
800
+ ctx.log?.info('hook.retirement', {
801
+ request_id: requestId,
802
+ attempted: true,
803
+ proven,
804
+ });
805
+ return proven;
688
806
  }
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
807
  /**
700
808
  * Park a delivered question for retirement. Called at the moment we learn it is
701
809
  * dead, which is frequently not a moment we can reach the network.
@@ -749,11 +857,36 @@ function retiringQuestion(pending, state, cwd) {
749
857
  state,
750
858
  };
751
859
  }
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;
860
+ const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay']);
861
+ function normalizePrompt(value) {
862
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
863
+ }
864
+ function pendingChoiceLabels(pending) {
865
+ const fromQuestions = (pending.questions ?? []).flatMap((question) => (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]));
866
+ return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0);
867
+ }
868
+ function pendingHasMatchingChoice(pending, normalizedPrompt) {
869
+ return pendingChoiceLabels(pending).includes(normalizedPrompt);
870
+ }
871
+ /**
872
+ * Which outstanding questions this prompt plausibly answers.
873
+ *
874
+ * A unique closed-choice match is enough. A short confirmation such as "done"
875
+ * matches only when exactly one question is outstanding. Long unrelated
876
+ * prompts leave every question open for the reply window.
877
+ */
878
+ export function pendingAnsweredByPrompt(prompt, pending) {
879
+ const normalized = normalizePrompt(prompt ?? '');
880
+ if (normalized.length === 0 || pending.length === 0)
881
+ return [];
882
+ const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized));
883
+ if (labelled.length === 1)
884
+ return labelled;
885
+ if (labelled.length > 1)
886
+ return [];
887
+ if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized))
888
+ return [...pending];
889
+ return [];
757
890
  }
758
891
  /**
759
892
  * Send every parked retirement, and forget the ones that landed. A failure
@@ -766,9 +899,8 @@ export async function drainRetirements(ctx, sessionId, env) {
766
899
  return [];
767
900
  const retired = [];
768
901
  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) {
902
+ const proven = await proveRetirement(ctx, entry.request_id);
903
+ if (proven) {
772
904
  retired.push(entry.request_id);
773
905
  // Persist each success before touching the next entry. A later corrupt
774
906
  // record or interrupted process must not resurrect work already done.
@@ -837,9 +969,8 @@ export async function drainOrphanRetirements(ctx, env, now) {
837
969
  updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
838
970
  continue;
839
971
  }
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) {
972
+ const proven = await proveRetirement(ctx, entry.request_id);
973
+ if (proven) {
843
974
  done.push(entry.request_id);
844
975
  updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
845
976
  }
@@ -893,10 +1024,15 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
893
1024
  }
894
1025
  /** Close several windows concurrently without confusing failure with silence. */
895
1026
  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
- })));
1027
+ return Promise.all(pending.map(async (entry) => {
1028
+ const response = await finalizeReplies(ctx, entry.request_id);
1029
+ ctx.log?.info('hook.retirement', {
1030
+ request_id: entry.request_id,
1031
+ attempted: true,
1032
+ proven: response !== null,
1033
+ });
1034
+ return { pending: entry, response };
1035
+ }));
900
1036
  }
901
1037
  /** Convert a fenced final reply set into the exact answer handed to the model. */
902
1038
  function finalizedAnswer(finalized) {
@@ -920,7 +1056,7 @@ function reportAnswer(ctx, notes, answered, late) {
920
1056
  ...(late ? { late: true } : {}),
921
1057
  request_id: answered.pending.request_id,
922
1058
  device: answered.reply.device_name,
923
- text: answered.reply.text,
1059
+ text_chars: answered.reply.text.length,
924
1060
  });
925
1061
  notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
926
1062
  }
@@ -964,7 +1100,7 @@ function isSamePending(a, b) {
964
1100
  function isTerminalDraftRejection(err) {
965
1101
  return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
966
1102
  }
967
- function dropPendingQuestion(sessionId, env, entry) {
1103
+ export function dropPendingQuestion(sessionId, env, entry) {
968
1104
  updateSessionState(sessionId, env, (current) => {
969
1105
  const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
970
1106
  const next = { ...current };
@@ -975,6 +1111,28 @@ function dropPendingQuestion(sessionId, env, entry) {
975
1111
  return next;
976
1112
  });
977
1113
  }
1114
+ /**
1115
+ * Drop registrations that never reached a device, so a later Stop cannot push
1116
+ * them. A retirement push would be noise: there is nothing on any device to
1117
+ * withdraw. Returns the withdrawn entries in registration order.
1118
+ */
1119
+ export function withdrawUnpushedQuestions(sessionId, env) {
1120
+ let withdrawn = [];
1121
+ updateSessionState(sessionId, env, (current) => {
1122
+ const pending = pendingList(current);
1123
+ withdrawn = pending.filter((entry) => entry.request_id === undefined);
1124
+ if (withdrawn.length === 0)
1125
+ return current;
1126
+ const remaining = pending.filter((entry) => entry.request_id !== undefined);
1127
+ const next = { ...current };
1128
+ if (remaining.length > 0)
1129
+ next.pending = remaining;
1130
+ else
1131
+ delete next.pending;
1132
+ return next;
1133
+ });
1134
+ return withdrawn;
1135
+ }
978
1136
  function clearFrozenSubmission(sessionId, env, entry) {
979
1137
  updateSessionState(sessionId, env, (current) => {
980
1138
  const list = pendingList(current);
@@ -1308,10 +1466,9 @@ async function retirePendings(ctx, envelope, sessionId, entries, state) {
1308
1466
  // UserPromptSubmit — the user is at the keyboard
1309
1467
  // ---------------------------------------------------------------------------
1310
1468
  /**
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.
1469
+ * Records presence. Retires a live question only when this prompt plausibly
1470
+ * answers it. Typing in the terminal is not by itself a resolution: the User
1471
+ * may be answering a different question on a device.
1315
1472
  */
1316
1473
  export async function handleUserPromptSubmit(ctx, envelope) {
1317
1474
  const notes = [];
@@ -1322,6 +1479,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1322
1479
  if (state.accepted !== undefined) {
1323
1480
  updateSessionState(sessionId, ctx.env, (current) => ({
1324
1481
  ...current,
1482
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
1325
1483
  last_prompt_at: ctx.now(),
1326
1484
  }));
1327
1485
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
@@ -1365,24 +1523,42 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1365
1523
  if (permanentFailure !== null)
1366
1524
  notes.push(permanentFailure);
1367
1525
  }
1526
+ const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state));
1527
+ for (const entry of matched) {
1528
+ ctx.log?.info('hook.retirement', {
1529
+ request_id: entry.request_id,
1530
+ question_id: entry.question_id,
1531
+ reason: 'prompt-matched',
1532
+ attempted: entry.request_id !== undefined,
1533
+ proven: false,
1534
+ });
1535
+ }
1368
1536
  // Park before dropping `pending`. If the process dies between these writes,
1369
1537
  // the next hook sees both copies and dedupes them; the old order could die in
1370
1538
  // the gap after erasing the only request/collapse/device identifiers.
1371
1539
  updateSessionState(sessionId, ctx.env, (current) => {
1372
1540
  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)) {
1541
+ const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current));
1542
+ const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
1543
+ for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
1375
1544
  const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
1376
1545
  if (retirement !== null &&
1377
1546
  !retiring.some((parked) => parked.request_id === retirement.request_id)) {
1378
1547
  retiring.push(retirement);
1379
1548
  }
1380
1549
  }
1550
+ const unasked = unmatched.filter((entry) => entry.request_id === undefined);
1551
+ const stillLive = unmatched.filter((entry) => entry.request_id !== undefined);
1552
+ const remaining = [...stillLive, ...unasked];
1381
1553
  // Start from the whole document so fields introduced by a newer CLI remain
1382
1554
  // 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;
1555
+ const next = {
1556
+ ...current,
1557
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
1558
+ last_prompt_at: ctx.now(),
1559
+ };
1560
+ if (remaining.length > 0)
1561
+ next.pending = remaining;
1386
1562
  else
1387
1563
  delete next.pending;
1388
1564
  if (retiring.length > 0)
@@ -1454,6 +1630,7 @@ export async function runEscalationWaiter(ctx, options) {
1454
1630
  // from the prompt-only state that previously looked healthy.
1455
1631
  updateSessionState(sessionId, ctx.env, (current) => ({
1456
1632
  ...current,
1633
+ ...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
1457
1634
  last_stop_at: ctx.now(),
1458
1635
  }));
1459
1636
  // The waiter, not its host, owns this lease. It spans grace, submission,
@@ -1661,6 +1838,10 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cw
1661
1838
  outcome.log = delivered.log;
1662
1839
  return outcome;
1663
1840
  }
1841
+ /** True while this Stop owner is still allowed a direct-wake wait. */
1842
+ function ownerLeaseActive(entry, now) {
1843
+ return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
1844
+ }
1664
1845
  /** The complete per-session Stop decision, after this process owns the claim. */
1665
1846
  async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt, route, continuingFromAnswer) {
1666
1847
  const live = pending.filter((entry) => entry.request_id !== undefined);
@@ -1715,18 +1896,34 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1715
1896
  return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1716
1897
  }
1717
1898
  const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1718
- liveToEscalate = recoverableLive;
1899
+ // A spent owner has already used its waiter ceiling. Passing those
1900
+ // questions back into `escalate` starts a fresh ceiling, holds the
1901
+ // per-session claim for another 3300s, and is why a later `ask` never
1902
+ // reached a device after a degraded waiter (the next Stop never ran, or
1903
+ // ran as claimed-elsewhere). Late answers still ride the short poll above
1904
+ // and every later turn. Independent questions whose lease is still running
1905
+ // keep their successor wait.
1906
+ const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
1907
+ liveToEscalate = stillOwned;
1719
1908
  if (unasked.length === 0) {
1720
1909
  if (recoverableLive.length === 0)
1721
1910
  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);
1911
+ if (stillOwned.length === 0) {
1912
+ const requestIdSummary = summarizeRequestIds(recoverableLive);
1913
+ ctx.log?.info('hook.answer', {
1914
+ answered: false,
1915
+ stage: 'queued',
1916
+ request_ids: requestIdSummary.ids,
1917
+ owner_spent: true,
1918
+ });
1919
+ 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`);
1920
+ return { notes };
1921
+ }
1922
+ return await escalate(ctx, envelope, sessionId, [], stillOwned, notes, hardDeadlineAt, route);
1727
1923
  }
1728
1924
  // Questions registered after the earlier push still owe the user their
1729
- // notification; fall through and escalate just those.
1925
+ // notification; fall through and escalate just those, plus any live
1926
+ // question whose owner lease is still running.
1730
1927
  }
1731
1928
  // A Stop answer may immediately produce a legitimate follow-up question.
1732
1929
  // Allow that new generation, but never re-run an old pending question and
@@ -1830,7 +2027,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1830
2027
  ...(entry.media !== undefined ? { media: entry.media } : {}),
1831
2028
  ...(entry.project !== undefined ? { project: entry.project } : {}),
1832
2029
  ...(eventSource !== undefined ? { source: eventSource } : {}),
1833
- event: 'agent_question',
1834
2030
  windowSeconds: replyWindowSeconds,
1835
2031
  ownerDeadlineAt,
1836
2032
  });
@@ -1909,7 +2105,6 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1909
2105
  ...(entry.media !== undefined ? { media: entry.media } : {}),
1910
2106
  ...(entry.project !== undefined ? { project: entry.project } : {}),
1911
2107
  ...(eventSource !== undefined ? { source: eventSource } : {}),
1912
- event: 'agent_question',
1913
2108
  windowSeconds: replyWindowSeconds,
1914
2109
  ownerDeadlineAt,
1915
2110
  });
@@ -1976,7 +2171,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1976
2171
  request_id: intent.request_id,
1977
2172
  devices: intent.device_ids.length,
1978
2173
  questions: questions.length,
1979
- text: questions[0].text,
2174
+ text_chars: questions[0].text.length,
1980
2175
  });
1981
2176
  }
1982
2177
  const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
@@ -1995,14 +2190,14 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1995
2190
  // stale about eight minutes later and retired, so the answer given over
1996
2191
  // lunch met a closed question.
1997
2192
  //
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.
2193
+ // The reply window is how long the server accepts an answer. This
2194
+ // process still clamps its own wait to `hardDeadlineAt`. Recording the
2195
+ // owner lease separately is what stops the *next* Stop from treating
2196
+ // the remaining window as a fresh waiter ceiling.
2003
2197
  reply_deadline_at: Number.isFinite(committedReplyDeadline)
2004
2198
  ? committedReplyDeadline
2005
2199
  : intent.owner_deadline_at,
2200
+ owner_deadline_at: intent.owner_deadline_at,
2006
2201
  };
2007
2202
  if (intent.draft.source === undefined)
2008
2203
  delete live.source;
@@ -2065,7 +2260,8 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2065
2260
  return { notes };
2066
2261
  // Phase two: one wait across everything live, old and new alike.
2067
2262
  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));
2263
+ const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000;
2264
+ const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000));
2069
2265
  const waited = await waitForAnyReply(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
2070
2266
  const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
2071
2267
  if (permanentFailure !== null)
@@ -2087,7 +2283,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2087
2283
  // ends still retires its questions — that is what the session-end hook is
2088
2284
  // for, and it is the honest place for it.
2089
2285
  const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
2090
- entry.reply_deadline_at <= ctx.now() ||
2286
+ entry.reply_deadline_at <= ownerDeadline ||
2091
2287
  // A permanent rejection is the server saying this question can never be
2092
2288
  // answered, whatever its window says. Leaving it open would strand a
2093
2289
  // dead question on the user's devices.
@@ -2126,9 +2322,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2126
2322
  permanent_failures: waited.permanentFailures.size,
2127
2323
  waited_seconds: timeoutSeconds,
2128
2324
  });
2129
- if (waited.permanentFailures.size === 0) {
2325
+ if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
2130
2326
  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'
2327
+ ? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
2132
2328
  : 'no answer in time; the question expired with its continuation owner');
2133
2329
  }
2134
2330
  return { notes };
@@ -2260,6 +2456,7 @@ export const MAX_LIVE_QUESTIONS = 10;
2260
2456
  */
2261
2457
  export function registerQuestion(sessionId, env, question, now = Date.now()) {
2262
2458
  let full = null;
2459
+ const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
2263
2460
  updateSessionState(sessionId, env, (state) => {
2264
2461
  const pending = pendingList(state);
2265
2462
  const unasked = pending.filter((entry) => entry.request_id === undefined);
@@ -2276,9 +2473,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
2276
2473
  pending: [
2277
2474
  ...pending,
2278
2475
  {
2279
- question_id: `q_${randomBytes(12).toString('base64url')}`,
2280
2476
  asked_at: now,
2281
2477
  ...question,
2478
+ question_id: questionId,
2282
2479
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
2283
2480
  },
2284
2481
  ],
@@ -2290,8 +2487,9 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
2290
2487
  }
2291
2488
  if (full === 'live') {
2292
2489
  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.');
2490
+ 'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
2294
2491
  }
2492
+ return questionId;
2295
2493
  }
2296
2494
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
2297
2495
  export function parseHookInput(raw) {