@raidiant/notifai 10.1.7 → 11.0.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 (154) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +23 -13
  3. package/dist/claude-wake.d.ts +1 -2
  4. package/dist/claude-wake.js +15 -61
  5. package/dist/claude-wake.js.map +1 -1
  6. package/dist/cli-bin.js +3 -14
  7. package/dist/cli-bin.js.map +1 -1
  8. package/dist/cli-contract.d.ts +12 -0
  9. package/dist/cli-contract.js +19 -0
  10. package/dist/cli-contract.js.map +1 -0
  11. package/dist/cli-release.d.ts +0 -1
  12. package/dist/cli-release.js +5 -21
  13. package/dist/cli-release.js.map +1 -1
  14. package/dist/client.d.ts +2 -2
  15. package/dist/codex-wake.d.ts +1 -1
  16. package/dist/codex-wake.js +8 -36
  17. package/dist/codex-wake.js.map +1 -1
  18. package/dist/command-session.js +2 -1
  19. package/dist/command-session.js.map +1 -1
  20. package/dist/commands-acknowledge.d.ts +13 -0
  21. package/dist/commands-acknowledge.js +67 -0
  22. package/dist/commands-acknowledge.js.map +1 -0
  23. package/dist/commands-ask.d.ts +63 -0
  24. package/dist/commands-ask.js +407 -0
  25. package/dist/commands-ask.js.map +1 -0
  26. package/dist/commands-close.d.ts +6 -0
  27. package/dist/commands-close.js +197 -0
  28. package/dist/commands-close.js.map +1 -0
  29. package/dist/commands-config.js +2 -2
  30. package/dist/commands-config.js.map +1 -1
  31. package/dist/commands-core.js +2 -2
  32. package/dist/commands-core.js.map +1 -1
  33. package/dist/commands-doctor.js +61 -120
  34. package/dist/commands-doctor.js.map +1 -1
  35. package/dist/commands-harness-context.d.ts +0 -1
  36. package/dist/commands-harness-context.js +2 -4
  37. package/dist/commands-harness-context.js.map +1 -1
  38. package/dist/commands-hook-diagnostics.d.ts +27 -0
  39. package/dist/commands-hook-diagnostics.js +97 -0
  40. package/dist/commands-hook-diagnostics.js.map +1 -0
  41. package/dist/commands-hook-install.d.ts +26 -0
  42. package/dist/commands-hook-install.js +551 -0
  43. package/dist/commands-hook-install.js.map +1 -0
  44. package/dist/commands-hook-run.d.ts +29 -0
  45. package/dist/commands-hook-run.js +517 -0
  46. package/dist/commands-hook-run.js.map +1 -0
  47. package/dist/commands-init.d.ts +2 -7
  48. package/dist/commands-init.js +59 -92
  49. package/dist/commands-init.js.map +1 -1
  50. package/dist/commands-send.js +35 -3
  51. package/dist/commands-send.js.map +1 -1
  52. package/dist/commands-skill.js +5 -5
  53. package/dist/commands-skill.js.map +1 -1
  54. package/dist/commands-update.js +17 -31
  55. package/dist/commands-update.js.map +1 -1
  56. package/dist/commands.d.ts +11 -6
  57. package/dist/commands.js +9 -4
  58. package/dist/commands.js.map +1 -1
  59. package/dist/config.d.ts +14 -4
  60. package/dist/config.js +35 -9
  61. package/dist/config.js.map +1 -1
  62. package/dist/file-lock.d.ts +3 -0
  63. package/dist/file-lock.js +33 -17
  64. package/dist/file-lock.js.map +1 -1
  65. package/dist/guidance-content.js +30 -15
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/hook-acknowledgements.d.ts +42 -0
  68. package/dist/hook-acknowledgements.js +316 -0
  69. package/dist/hook-acknowledgements.js.map +1 -0
  70. package/dist/hook-events.d.ts +6 -0
  71. package/dist/hook-events.js +22 -0
  72. package/dist/hook-events.js.map +1 -0
  73. package/dist/hook-gates.d.ts +7 -0
  74. package/dist/hook-gates.js +21 -0
  75. package/dist/hook-gates.js.map +1 -0
  76. package/dist/hook-lifecycle.d.ts +93 -0
  77. package/dist/{hooks.js → hook-lifecycle.js} +61 -1359
  78. package/dist/hook-lifecycle.js.map +1 -0
  79. package/dist/hook-project-sessions.d.ts +28 -0
  80. package/dist/hook-project-sessions.js +141 -0
  81. package/dist/hook-project-sessions.js.map +1 -0
  82. package/dist/hook-question-lock.d.ts +7 -0
  83. package/dist/hook-question-lock.js +173 -0
  84. package/dist/hook-question-lock.js.map +1 -0
  85. package/dist/hook-question-retirement.d.ts +57 -0
  86. package/dist/hook-question-retirement.js +261 -0
  87. package/dist/hook-question-retirement.js.map +1 -0
  88. package/dist/hook-question-state.d.ts +45 -0
  89. package/dist/hook-question-state.js +196 -0
  90. package/dist/hook-question-state.js.map +1 -0
  91. package/dist/hook-session-state.d.ts +31 -0
  92. package/dist/hook-session-state.js +249 -0
  93. package/dist/hook-session-state.js.map +1 -0
  94. package/dist/{hooks.d.ts → hook-types.d.ts} +17 -236
  95. package/dist/hook-types.js +4 -0
  96. package/dist/hook-types.js.map +1 -0
  97. package/dist/install-hooks.d.ts +75 -60
  98. package/dist/install-hooks.js +299 -209
  99. package/dist/install-hooks.js.map +1 -1
  100. package/dist/interactive.d.ts +3 -16
  101. package/dist/interactive.js +19 -95
  102. package/dist/interactive.js.map +1 -1
  103. package/dist/local-path.d.ts +7 -0
  104. package/dist/local-path.js +27 -0
  105. package/dist/local-path.js.map +1 -0
  106. package/dist/node-floor.js +3 -10
  107. package/dist/node-floor.js.map +1 -1
  108. package/dist/npm-invocation.d.ts +34 -0
  109. package/dist/npm-invocation.js +53 -0
  110. package/dist/npm-invocation.js.map +1 -0
  111. package/dist/openclaw-plugin.d.ts +12 -3
  112. package/dist/openclaw-plugin.js +42 -16
  113. package/dist/openclaw-plugin.js.map +1 -1
  114. package/dist/opencode-plugin.d.ts +2 -2
  115. package/dist/opencode-plugin.js +4 -6
  116. package/dist/opencode-plugin.js.map +1 -1
  117. package/dist/program.js +11 -13
  118. package/dist/program.js.map +1 -1
  119. package/dist/project-enablement.d.ts +6 -0
  120. package/dist/project-enablement.js +11 -27
  121. package/dist/project-enablement.js.map +1 -1
  122. package/dist/question-settlement-process.d.ts +1 -1
  123. package/dist/readiness.d.ts +0 -2
  124. package/dist/readiness.js +0 -4
  125. package/dist/readiness.js.map +1 -1
  126. package/dist/send.d.ts +8 -4
  127. package/dist/send.js +145 -26
  128. package/dist/send.js.map +1 -1
  129. package/dist/session-activation.d.ts +3 -1
  130. package/dist/session-activation.js +16 -0
  131. package/dist/session-activation.js.map +1 -1
  132. package/dist/skill-source/manifest.json +4 -4
  133. package/dist/skill-source/notifai/SKILL.md +60 -60
  134. package/dist/skill-source/notifai/references/harness-setup.md +42 -16
  135. package/dist/ui/config-view.d.ts +0 -2
  136. package/dist/ui/config-view.js +1 -5
  137. package/dist/ui/config-view.js.map +1 -1
  138. package/dist/ui/theme.d.ts +0 -3
  139. package/dist/ui/theme.js +0 -1
  140. package/dist/ui/theme.js.map +1 -1
  141. package/dist/version.d.ts +22 -0
  142. package/dist/version.js +51 -0
  143. package/dist/version.js.map +1 -0
  144. package/dist/wake-support.d.ts +8 -0
  145. package/dist/wake-support.js +38 -0
  146. package/dist/wake-support.js.map +1 -0
  147. package/package.json +2 -2
  148. package/dist/cli-update-contract.d.ts +0 -7
  149. package/dist/cli-update-contract.js +0 -10
  150. package/dist/cli-update-contract.js.map +0 -1
  151. package/dist/commands-hooks.d.ts +0 -160
  152. package/dist/commands-hooks.js +0 -1800
  153. package/dist/commands-hooks.js.map +0 -1
  154. package/dist/hooks.js.map +0 -1
@@ -1,43 +1,17 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
- import path from 'node:path';
4
- import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol';
5
- import { LEGACY_QUESTION_CLAIM_TTL_SECONDS, QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
6
- import { ApiCallError, isRetryableReplyPollError, } from './client.js';
7
- import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
8
- import { buildDraft } from './send.js';
9
- import { atomicWriteFileSync } from './atomic-file.js';
2
+ import { ApiCallError, isRetryableReplyPollError } from './client.js';
10
3
  import { withFileLock } from './file-lock.js';
11
- import { inferInvocationContext } from './invocation-context.js';
12
- import { HARNESS_CAPABILITIES, HOOK_INSTALLABLE_HARNESSES, } from './harnesses.js';
13
- /**
14
- * A retirement is only worth syncing while the question it retires could still
15
- * be answered, so this has to outlive the longest reply window a user can
16
- * choose — it is derived from that bound rather than from a guess about how
17
- * long questions stay open, which is exactly the assumption that broke when
18
- * the window stopped being one hour. Past it the companion shows the question
19
- * as dead on next open anyway. Also the backstop that keeps an unreachable
20
- * server from growing the queue for ever.
21
- */
22
- const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000;
23
- /** More orphans than this means something is looping; keep the newest. */
24
- const ORPHAN_QUEUE_CAP = 50;
25
- /** Keep immutable session grouping while replacing per-event Git location authoritatively. */
26
- function sourceContextAtHookEvent(source, cwd) {
27
- const invocation = cwd === undefined ? null : inferInvocationContext(cwd);
28
- const current = {
29
- ...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
30
- ...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
31
- ...(source?.harness !== undefined ? { harness: source.harness } : {}),
32
- ...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
33
- ...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
34
- };
35
- return Object.keys(current).length === 0 ? undefined : current;
36
- }
37
- function summarizeRequestIds(entries) {
38
- const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
39
- return { ids, display: ids.join(', ') };
40
- }
4
+ import { HARNESS_CAPABILITIES } from './harnesses.js';
5
+ import { gate } from './hook-gates.js';
6
+ import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, finishCommittedDelivery, holdForAcknowledgement, reconcileAcknowledgementObligations, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
7
+ import { clearMatchingProjectSession, writeProjectSession } from './hook-project-sessions.js';
8
+ import { claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
9
+ import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
10
+ import { clearFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
11
+ import { clearSessionState, markSessionEnded, pendingList, readSessionState, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionState, writeSessionStateUnlocked, } from './hook-session-state.js';
12
+ import { userPromptContextOutput } from './session-activation.js';
13
+ import { QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
14
+ import { buildDraft } from './send.js';
41
15
  /**
42
16
  * The wire contract's shortest reply window. A question submitted with less
43
17
  * than this is rejected outright, and accepting one would in any case let the
@@ -70,409 +44,6 @@ async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
70
44
  if (remaining > 0)
71
45
  await ctx.sleep(remaining);
72
46
  }
73
- function sessionStatePath(sessionId, env) {
74
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`);
75
- }
76
- /**
77
- * Durable cancellation for observers that can outlive the harness process.
78
- *
79
- * Absence of session state is not enough: SessionEnd deliberately deletes it,
80
- * and an in-flight submit/fence could otherwise recreate it afterwards. The
81
- * marker shares the session-state lock, giving every racing writer one total
82
- * order: either its state lands before SessionEnd and is cleaned up there, or
83
- * it observes this marker and must not write or deliver into the ended session.
84
- */
85
- function sessionEndMarkerPath(sessionId, env) {
86
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.ended`);
87
- }
88
- function sessionHasEnded(sessionId, env) {
89
- return existsSync(sessionEndMarkerPath(sessionId, env));
90
- }
91
- function markSessionEnded(sessionId, env, now) {
92
- const stateFile = sessionStatePath(sessionId, env);
93
- withFileLock(`${stateFile}.lock`, () => {
94
- atomicWriteFileSync(sessionEndMarkerPath(sessionId, env), `${now}\n`);
95
- });
96
- }
97
- function clearSessionEndMarker(sessionId, env) {
98
- const stateFile = sessionStatePath(sessionId, env);
99
- withFileLock(`${stateFile}.lock`, () => {
100
- rmSync(sessionEndMarkerPath(sessionId, env), { force: true });
101
- });
102
- }
103
- export function readSessionState(sessionId, env) {
104
- const file = sessionStatePath(sessionId, env);
105
- if (!existsSync(file))
106
- return {};
107
- try {
108
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
109
- if (typeof parsed !== 'object' || parsed === null)
110
- return {};
111
- const state = { ...parsed };
112
- delete state.session_id;
113
- return state;
114
- }
115
- catch {
116
- // A corrupt marker must not wedge the harness; treat it as "no evidence",
117
- // which fails closed onto ordinary terminal behaviour.
118
- return {};
119
- }
120
- }
121
- /**
122
- * Resolve a stable question or request id to the one exact session that owns it.
123
- *
124
- * This deliberately searches lifecycle state, not checkout pointers: question
125
- * identity is global on this machine, while a linked worktree is only where a
126
- * command happened to run. Ambiguous or corrupt matches fail closed.
127
- */
128
- export function findOwningSession(id, env) {
129
- const directory = path.join(stateDir(env), 'sessions');
130
- if (!existsSync(directory))
131
- return { sessionId: null, ambiguous: false };
132
- const matches = [];
133
- for (const name of readdirSync(directory)) {
134
- if (!name.endsWith('.json'))
135
- continue;
136
- let persisted = null;
137
- try {
138
- const parsed = JSON.parse(readFileSync(path.join(directory, name), 'utf8'));
139
- if (typeof parsed === 'object' && parsed !== null) {
140
- persisted = parsed;
141
- }
142
- }
143
- catch {
144
- // Corrupt lifecycle state is not identity evidence.
145
- }
146
- if (persisted === null || typeof persisted.session_id !== 'string')
147
- continue;
148
- const sessionId = persisted.session_id;
149
- const state = persisted;
150
- const pendingMatch = pendingList(state).some((entry) => entry.question_id === id || entry.request_id === id);
151
- const retiringMatch = (state.retiring ?? []).some((entry) => entry.question_id === id || entry.request_id === id);
152
- const historyMatch = (state.question_history ?? []).some((entry) => entry.question_id === id || entry.request_id === id);
153
- const acknowledgementMatch = (state.acknowledgement_due ?? []).some((entry) => entry.request_id === id);
154
- const acceptedMatch = state.accepted?.answers.some(({ pending }) => pending.question_id === id || pending.request_id === id) ?? false;
155
- if (pendingMatch || retiringMatch || historyMatch || acknowledgementMatch || acceptedMatch) {
156
- matches.push(sessionId);
157
- if (matches.length > 1)
158
- return { sessionId: null, ambiguous: true };
159
- }
160
- }
161
- return { sessionId: matches[0] ?? null, ambiguous: false };
162
- }
163
- export function writeSessionState(sessionId, env, state) {
164
- const file = sessionStatePath(sessionId, env);
165
- withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state));
166
- }
167
- export function clearSessionState(sessionId, env) {
168
- const file = sessionStatePath(sessionId, env);
169
- withFileLock(`${file}.lock`, () => {
170
- rmSync(file, { force: true });
171
- // The session override lives in a sibling file; leaving it behind meant a
172
- // later session reusing the id silently inherited `ask_notifications = false`.
173
- rmSync(sessionConfigPath(sessionId, env), { force: true });
174
- });
175
- }
176
- function writeSessionStateUnlocked(file, sessionId, state) {
177
- atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`);
178
- }
179
- function updateSessionState(sessionId, env, update) {
180
- const file = sessionStatePath(sessionId, env);
181
- return withFileLock(`${file}.lock`, () => {
182
- const current = readSessionState(sessionId, env);
183
- // SessionEnd and every asynchronous writer share this lock. Once the
184
- // durable marker exists, no observer may recreate state for that session.
185
- if (sessionHasEnded(sessionId, env))
186
- return current;
187
- const next = update(current);
188
- writeSessionStateUnlocked(file, sessionId, next);
189
- return next;
190
- });
191
- }
192
- /** Record the current installation when an owner session starts. */
193
- export function recordSessionStart(sessionId, env, harness, cwd, codexStopDefinitionFingerprint) {
194
- // Harnesses may reuse a session id only by explicitly starting that session
195
- // again. That lifecycle edge is the sole authority for clearing cancellation.
196
- clearSessionEndMarker(sessionId, env);
197
- updateSessionState(sessionId, env, (current) => {
198
- const next = {
199
- ...current,
200
- ...(harness === undefined ? {} : { harness }),
201
- ...(cwd === undefined
202
- ? {}
203
- : { activation_cwd: harness === 'codex' ? cwd : current.activation_cwd ?? cwd }),
204
- };
205
- // A Codex SessionStart materializes one new runtime generation. Absence of
206
- // singular proof must replace prior proof too; retaining it would let a
207
- // resumed session borrow the definition loaded by an earlier runtime.
208
- if (harness === 'codex') {
209
- delete next.codex_stop_definition_fingerprint;
210
- if (codexStopDefinitionFingerprint !== undefined) {
211
- next.codex_stop_definition_fingerprint = codexStopDefinitionFingerprint;
212
- }
213
- }
214
- return next;
215
- });
216
- }
217
- /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
218
- export function claimCursorStopActivation(sessionId, env, now) {
219
- const claimGuardMs = 30_000;
220
- let claimed = false;
221
- updateSessionState(sessionId, env, (current) => {
222
- if (current.cursor_activation_confirmed_at !== undefined)
223
- return current;
224
- if (current.cursor_activation_claimed_at !== undefined &&
225
- now - current.cursor_activation_claimed_at < claimGuardMs) {
226
- return current;
227
- }
228
- claimed = true;
229
- return { ...current, cursor_activation_claimed_at: now };
230
- });
231
- return claimed;
232
- }
233
- /** Cursor's loop_count proves the previous Stop follow-up reached the host. */
234
- export function confirmCursorStopActivation(sessionId, env, now) {
235
- let owned = false;
236
- updateSessionState(sessionId, env, (current) => {
237
- if (current.cursor_activation_confirmed_at !== undefined) {
238
- owned = true;
239
- return current;
240
- }
241
- if (current.cursor_activation_claimed_at === undefined)
242
- return current;
243
- owned = true;
244
- return { ...current, cursor_activation_confirmed_at: now };
245
- });
246
- return owned;
247
- }
248
- /** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
249
- export function resetCursorStopActivation(sessionId, env) {
250
- updateSessionState(sessionId, env, (current) => {
251
- if (current.cursor_activation_claimed_at === undefined &&
252
- current.cursor_activation_confirmed_at === undefined) {
253
- return current;
254
- }
255
- const next = { ...current };
256
- delete next.cursor_activation_claimed_at;
257
- delete next.cursor_activation_confirmed_at;
258
- return next;
259
- });
260
- }
261
- /**
262
- * Session state a crashed harness left behind.
263
- *
264
- * `SessionEnd` removes session state and leaves a short-lived cancellation
265
- * marker; a harness that crashes or is killed never reaches it. At roughly a
266
- * hundred sessions a day that is tens of thousands of files a year, none of
267
- * which anything reads.
268
- *
269
- * Opportunistic rather than scheduled: hooks are the only thing that runs, so
270
- * a hook is where this has to live. It is rate-limited by its own stamp file
271
- * so the common case costs one `stat`, not a directory walk — a Stop hook is
272
- * on the critical path of every turn.
273
- */
274
- const SESSION_PRUNE_AFTER_MS = 7 * 24 * 3600 * 1000;
275
- const SESSION_PRUNE_INTERVAL_MS = 24 * 3600 * 1000;
276
- export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION_PRUNE_AFTER_MS) {
277
- const directory = path.join(stateDir(env), 'sessions');
278
- const stamp = path.join(stateDir(env), 'last-prune');
279
- try {
280
- if (existsSync(stamp) && now - statSync(stamp).mtimeMs < SESSION_PRUNE_INTERVAL_MS)
281
- return 0;
282
- mkdirSync(path.dirname(stamp), { recursive: true });
283
- writeFileSync(stamp, '', { mode: 0o600 });
284
- if (!existsSync(directory))
285
- return 0;
286
- let removed = 0;
287
- for (const name of readdirSync(directory)) {
288
- const file = path.join(directory, name);
289
- try {
290
- const age = now - statSync(file).mtimeMs;
291
- // A negative age means the clock moved, not that the file is old.
292
- // Deleting live session state on an NTP correction would lose a
293
- // question already on the user's phone.
294
- if (age <= maxAgeMs)
295
- continue;
296
- rmSync(file, { force: true });
297
- removed += 1;
298
- }
299
- catch {
300
- // A file that vanished under us, or one we may not read. Neither is
301
- // worth failing a hook for, and the next pass will see it again.
302
- }
303
- }
304
- return removed;
305
- }
306
- catch {
307
- // Housekeeping must never be the reason a turn fails.
308
- return 0;
309
- }
310
- }
311
- /**
312
- * One question, one push, even with two Stop hooks racing.
313
- *
314
- * Path-independent hook ownership stops the *usual* cause of two handlers
315
- * firing, but it cannot stop every one — two harnesses in one directory, or an
316
- * install this build does not recognise. Both processes would read the same
317
- * pending question, see no `request_id`, and both escalate: one question, two
318
- * notifications.
319
- *
320
- * Exclusive create is atomic on POSIX, so exactly one process gets the claim
321
- * and the other steps aside. A short guard serializes stale replacement with
322
- * contenders and releases; random ownership tokens keep an old holder from
323
- * unlinking its replacement. A live PID owns the claim regardless of age; a
324
- * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
325
- * no trustworthy PID fall back to an age limit.
326
- */
327
- const CLAIM_TTL_MS = LEGACY_QUESTION_CLAIM_TTL_SECONDS * 1000;
328
- /**
329
- * How long a crashed claim *guard* blocks the next hook.
330
- *
331
- * The claim itself may legitimately be held for the whole waiter, so it takes
332
- * that ceiling. The guard is a short lock around the claim's own bookkeeping —
333
- * a few file operations — and borrowing the waiter's ceiling for it meant a
334
- * hook killed at exactly the wrong moment wedged this session's Stop path for
335
- * eight minutes, for a critical section that never runs longer than a second.
336
- */
337
- const CLAIM_GUARD_TTL_MS = 30_000;
338
- const heldClaims = new Map();
339
- export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace, ownerDeadlineAt) {
340
- const file = claimPath(sessionId, env);
341
- const guard = `${file}.guard`;
342
- const token = randomBytes(12).toString('base64url');
343
- const pendingQuestionIds = pendingList(readSessionState(sessionId, env)).map(claimQuestionIdentity);
344
- mkdirSync(path.dirname(file), { recursive: true });
345
- if (!acquireClaimGuard(guard))
346
- return false;
347
- try {
348
- try {
349
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`, {
350
- mode: 0o600,
351
- flag: 'wx',
352
- });
353
- heldClaims.set(file, token);
354
- return true;
355
- }
356
- catch {
357
- // Held. Break it only if whoever holds it cannot still be running.
358
- try {
359
- const held = JSON.parse(readFileSync(file, 'utf8'));
360
- const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY;
361
- if (typeof held.pid === 'number') {
362
- if (processIsAlive(held.pid))
363
- return false;
364
- }
365
- else if (age >= 0 && age < CLAIM_TTL_MS) {
366
- return false;
367
- }
368
- }
369
- catch {
370
- // Unreadable or corrupt: treat as abandoned.
371
- }
372
- beforeStaleReplace?.();
373
- rmSync(file, { force: true });
374
- try {
375
- writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token, pending_question_ids: pendingQuestionIds, ...(ownerDeadlineAt === undefined ? {} : { owner_deadline_at: ownerDeadlineAt }) })}\n`, {
376
- mode: 0o600,
377
- flag: 'wx',
378
- });
379
- heldClaims.set(file, token);
380
- return true;
381
- }
382
- catch {
383
- return false;
384
- }
385
- }
386
- }
387
- finally {
388
- rmSync(guard, { force: true });
389
- }
390
- }
391
- function claimQuestionIdentity(entry) {
392
- return entry.question_id ?? `${entry.asked_at ?? 'legacy'}\u0000${entry.question}`;
393
- }
394
- /** True only when the live owner could not have snapshotted this unpushed ask. */
395
- function claimHandoffState(sessionId, env) {
396
- const unasked = pendingList(readSessionState(sessionId, env)).filter((entry) => entry.request_id === undefined);
397
- if (unasked.length === 0)
398
- return { hasNewQuestion: false };
399
- try {
400
- const held = JSON.parse(readFileSync(claimPath(sessionId, env), 'utf8'));
401
- if (!Array.isArray(held.pending_question_ids))
402
- return { hasNewQuestion: false };
403
- const snapshotted = new Set(held.pending_question_ids.filter((entry) => typeof entry === 'string'));
404
- return {
405
- hasNewQuestion: unasked.some((entry) => !snapshotted.has(claimQuestionIdentity(entry))),
406
- ...(typeof held.owner_deadline_at === 'number' &&
407
- Number.isFinite(held.owner_deadline_at)
408
- ? { ownerDeadlineAt: held.owner_deadline_at }
409
- : {}),
410
- };
411
- }
412
- catch {
413
- return { hasNewQuestion: false };
414
- }
415
- }
416
- function processIsAlive(pid) {
417
- try {
418
- process.kill(pid, 0);
419
- return true;
420
- }
421
- catch (err) {
422
- return err.code === 'EPERM';
423
- }
424
- }
425
- export function releaseQuestionPush(sessionId, env) {
426
- const file = claimPath(sessionId, env);
427
- const token = heldClaims.get(file);
428
- if (token === undefined)
429
- return;
430
- const guard = `${file}.guard`;
431
- if (!acquireClaimGuard(guard)) {
432
- heldClaims.delete(file);
433
- return;
434
- }
435
- try {
436
- try {
437
- const held = JSON.parse(readFileSync(file, 'utf8'));
438
- if (held.token === token)
439
- rmSync(file, { force: true });
440
- }
441
- catch {
442
- // Missing or replaced claims are not ours to release.
443
- }
444
- }
445
- finally {
446
- heldClaims.delete(file);
447
- rmSync(guard, { force: true });
448
- }
449
- }
450
- /** The guard serializes stale replacement and release; recover a crashed guard. */
451
- function acquireClaimGuard(file) {
452
- for (let attempt = 0; attempt < 2; attempt += 1) {
453
- try {
454
- const handle = openSync(file, 'wx', 0o600);
455
- closeSync(handle);
456
- return true;
457
- }
458
- catch (err) {
459
- if (err.code !== 'EEXIST')
460
- return false;
461
- try {
462
- if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS)
463
- return false;
464
- }
465
- catch {
466
- // A vanished/corrupt guard is safe to retry once.
467
- }
468
- rmSync(file, { force: true });
469
- }
470
- }
471
- return false;
472
- }
473
- function claimPath(sessionId, env) {
474
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.claim`);
475
- }
476
47
  /**
477
48
  * A question is stored so a later hook can push it, and it reaches us from a
478
49
  * shell command, so its size is whatever the agent typed. The push itself is
@@ -481,166 +52,6 @@ function claimPath(sessionId, env) {
481
52
  * megabytes per session.
482
53
  */
483
54
  const MAX_STORED_QUESTION_CHARS = 2000;
484
- function storedProjectSessionPointer(candidate) {
485
- if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
486
- return null;
487
- const entry = candidate;
488
- if (typeof entry.session_id !== 'string' || entry.session_id === '')
489
- return null;
490
- if (typeof entry.updated_at !== 'number')
491
- return null;
492
- if (!HOOK_INSTALLABLE_HARNESSES.includes(entry.harness))
493
- return null;
494
- return {
495
- sessionId: entry.session_id,
496
- updatedAt: entry.updated_at,
497
- harness: entry.harness,
498
- };
499
- }
500
- function readStoredProjectSessionDocument(file) {
501
- if (!existsSync(file))
502
- return { root: {}, sessions: [] };
503
- try {
504
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
505
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
506
- return { root: {}, sessions: [] };
507
- }
508
- const root = parsed;
509
- return {
510
- root,
511
- sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
512
- };
513
- }
514
- catch {
515
- return { root: {}, sessions: [] };
516
- }
517
- }
518
- /** Records every live session working in a directory, for `notifai ask`. */
519
- export function writeProjectSession(cwd, env, sessionId, now, harness) {
520
- const file = projectSessionPointerPath(cwd, env);
521
- withFileLock(`${file}.lock`, () => {
522
- const stored = readStoredProjectSessionDocument(file);
523
- const existing = [];
524
- let refreshed = {};
525
- for (const candidate of stored.sessions) {
526
- const entry = storedProjectSessionPointer(candidate);
527
- if (entry?.sessionId === sessionId) {
528
- refreshed = { ...refreshed, ...candidate };
529
- }
530
- else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
531
- existing.push(candidate);
532
- }
533
- }
534
- atomicWriteFileSync(file, `${JSON.stringify({
535
- ...stored.root,
536
- sessions: [
537
- ...existing,
538
- { ...refreshed, session_id: sessionId, updated_at: now, harness },
539
- ],
540
- })}\n`);
541
- });
542
- }
543
- function readStoredProjectSessionPointers(file) {
544
- return readStoredProjectSessionDocument(file).sessions.flatMap((candidate) => {
545
- const entry = storedProjectSessionPointer(candidate);
546
- return entry === null ? [] : [entry];
547
- });
548
- }
549
- /**
550
- * Resolves the session working in `cwd`. Stale pointers are ignored rather than
551
- * trusted: an id left by a session that ended days ago would send the question
552
- * into state nothing is watching.
553
- */
554
- export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
555
- return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null;
556
- }
557
- /**
558
- * Every session that has fired a hook in this directory recently, newest first.
559
- *
560
- * This is the only local record of which harness sessions are actually alive
561
- * here, which makes it the tiebreaker when the environment alone cannot say
562
- * which harness owns the current shell.
563
- */
564
- export function readLiveProjectSessionPointers(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
565
- const file = projectSessionPointerPath(cwd, env);
566
- return readStoredProjectSessionPointers(file)
567
- .filter((entry) => now - entry.updatedAt <= maxAgeMs)
568
- .filter((entry) => {
569
- try {
570
- // A pointer is routing evidence only while its session state still
571
- // exists and parses. SessionEnd and explicit cleanup therefore
572
- // invalidate it even when a crash prevented removal from this index.
573
- const sessionFile = sessionStatePath(entry.sessionId, env);
574
- if (!existsSync(sessionFile))
575
- return false;
576
- const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
577
- return typeof state === 'object' && state !== null && !Array.isArray(state);
578
- }
579
- catch {
580
- return false;
581
- }
582
- })
583
- .sort((left, right) => right.updatedAt - left.updatedAt)
584
- .map((entry) => ({
585
- sessionId: entry.sessionId,
586
- harness: entry.harness,
587
- updatedAt: entry.updatedAt,
588
- }));
589
- }
590
- export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
591
- const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0];
592
- return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness };
593
- }
594
- export function readMatchingProjectSessionPointer(cwd, env, now, sessionId, harness, maxAgeMs = 24 * 3600 * 1000) {
595
- const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find((entry) => entry.sessionId === sessionId && entry.harness === harness);
596
- return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness };
597
- }
598
- function clearMatchingProjectSession(cwd, env, sessionId) {
599
- const file = projectSessionPointerPath(cwd, env);
600
- withFileLock(`${file}.lock`, () => {
601
- const stored = readStoredProjectSessionDocument(file);
602
- const remaining = stored.sessions.filter((candidate) => {
603
- const entry = storedProjectSessionPointer(candidate);
604
- return entry === null || entry.sessionId !== sessionId;
605
- });
606
- const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions');
607
- if (remaining.length === 0 && !hasUnknownRootFields) {
608
- rmSync(file, { force: true });
609
- return;
610
- }
611
- atomicWriteFileSync(file, `${JSON.stringify({
612
- ...stored.root,
613
- sessions: remaining,
614
- })}\n`);
615
- });
616
- }
617
- /**
618
- * Record one gate decision.
619
- *
620
- * `verdict` is what happened to the question and `reason` is the closed-set
621
- * name of the rule that decided it, so an agent can ask "why was it held" with
622
- * a filter rather than by matching on prose. The prose still exists — it is what
623
- * the user sees in the terminal — but it is free to be rewritten, and a log a
624
- * filter cannot rely on is a log nobody filters.
625
- */
626
- export const GATE_REASONS = [
627
- 'no-session',
628
- 'no-question',
629
- 'answered',
630
- 'continuation-repeat',
631
- 'continuation-limit',
632
- 'delivery-limit',
633
- 'acknowledgement-required',
634
- 'acknowledgement-abandoned',
635
- 'harness-cannot-continue',
636
- 'notifications-off',
637
- 'claimed-elsewhere',
638
- 'elapsed',
639
- 'proceeding',
640
- ];
641
- function gate(ctx, verdict, reason, data = {}) {
642
- ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data });
643
- }
644
55
  /**
645
56
  * One round of the waiter's reply poll. Short enough that several questions
646
57
  * still share one wall clock, long enough not to hammer the server.
@@ -672,8 +83,9 @@ async function prepareQuestionSubmission(ctx, options) {
672
83
  return { error: 'no device can answer a question yet; leaving this to the terminal' };
673
84
  }
674
85
  const build = buildDraft(ctx.config, {
675
- title: options.title,
676
- body: options.body,
86
+ title: options.questions[0].text,
87
+ summary: options.summary,
88
+ ...(options.body !== undefined ? { body: options.body } : {}),
677
89
  lifecycle: { tier: 'needs_you' },
678
90
  ...(options.project !== undefined ? { project: options.project } : {}),
679
91
  ...(options.media !== undefined ? { media: options.media } : {}),
@@ -827,221 +239,6 @@ async function answerableDevices(ctx) {
827
239
  .filter((device) => configured === null || configured.includes(device.device_id))
828
240
  .map((device) => device.device_id);
829
241
  }
830
- /**
831
- * Close is a server-side serialization fence: a successful response contains
832
- * every reply that committed before the window closed. `null` is deliberately
833
- * different from an empty reply set — it means ownership could not be proven
834
- * final and the local record must stay recoverable.
835
- */
836
- async function finalizeReplies(ctx, requestId) {
837
- try {
838
- return await ctx.client.closeReplies(requestId);
839
- }
840
- catch {
841
- return null;
842
- }
843
- }
844
- /** Best effort only for already-journaled retirement debt. */
845
- async function closeQuietly(ctx, requestId) {
846
- await finalizeReplies(ctx, requestId);
847
- }
848
- /**
849
- * Close the reply window so the server can mint the retirement push. Client
850
- * submissions cannot carry `question_retired`; that event is reserved for
851
- * server-origin state syncs.
852
- */
853
- async function proveRetirement(ctx, requestId) {
854
- const response = await finalizeReplies(ctx, requestId);
855
- const proven = response !== null;
856
- ctx.log?.info('hook.retirement', {
857
- request_id: requestId,
858
- attempted: true,
859
- proven,
860
- });
861
- return proven;
862
- }
863
- /**
864
- * Park a delivered question for retirement. Called at the moment we learn it is
865
- * dead, which is frequently not a moment we can reach the network.
866
- *
867
- * Nothing is parked for a question that never reached a device: with no
868
- * request_id there is nothing on any device to retire.
869
- */
870
- export function parkForRetirement(sessionId, env, pending, state) {
871
- const retirement = retiringQuestion(pending, state);
872
- if (retirement === null)
873
- return;
874
- updateSessionState(sessionId, env, (current) => {
875
- const already = current.retiring ?? [];
876
- const existing = already.findIndex((entry) => entry.request_id === retirement.request_id);
877
- if (existing < 0) {
878
- return rememberQuestionState({ ...current, retiring: [...already, retirement] }, pending, state === 'answered' ? 'answered' : 'retired');
879
- }
880
- const retiring = [...already];
881
- // An observed answer is final truth and upgrades an earlier supersession
882
- // parked while the submission callback was racing a newer question.
883
- if (retirement.state === 'answered' && retiring[existing].state !== 'answered') {
884
- retiring[existing] = retirement;
885
- }
886
- return rememberQuestionState({ ...current, retiring }, pending, state === 'answered' ? 'answered' : 'retired');
887
- });
888
- }
889
- /**
890
- * Convert live question state into a complete retirement instruction.
891
- *
892
- * A request/collapse pair without the original Delivery targets is not safe to
893
- * send: re-resolving today's routing can miss a device that still shows the
894
- * question or retire a device that never received it. Fail explicitly and keep
895
- * the pending record instead of silently broadening or narrowing the fanout.
896
- */
897
- function retiringQuestion(pending, state, cwd) {
898
- // A submit journal is written before the first network byte. SessionEnd can
899
- // therefore race a request that the server accepts after local cleanup. Its
900
- // reserved request id and exact fanout are already sufficient retirement
901
- // identity, even though the ordinary live fields have not been promoted yet.
902
- const requestId = pending.request_id ?? pending.submission?.request_id;
903
- const collapseKey = pending.collapse_key ?? pending.submission?.collapse_key;
904
- const deviceIds = pending.device_ids ?? pending.submission?.device_ids;
905
- const hasRequest = requestId !== undefined;
906
- const hasCollapse = collapseKey !== undefined;
907
- const hasDevices = deviceIds !== undefined && deviceIds.length > 0;
908
- if (!hasRequest && !hasCollapse && !hasDevices)
909
- return null;
910
- if (!hasRequest || !hasCollapse || !hasDevices) {
911
- throw new Error('live question state is incomplete; refusing to retire it without request, collapse, and device identifiers');
912
- }
913
- const source = sourceContextAtHookEvent(pending.source, cwd);
914
- return {
915
- ...(pending.question_id !== undefined ? { question_id: pending.question_id } : {}),
916
- request_id: requestId,
917
- collapse_key: collapseKey,
918
- device_ids: [...deviceIds],
919
- question: pending.question,
920
- ...(pending.project !== undefined ? { project: pending.project } : {}),
921
- ...(source !== undefined ? { source } : {}),
922
- state,
923
- };
924
- }
925
- const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay']);
926
- function normalizePrompt(value) {
927
- return value.trim().replace(/\s+/g, ' ').toLowerCase();
928
- }
929
- function pendingChoiceLabels(pending) {
930
- const fromQuestions = (pending.questions ?? []).flatMap((question) => (question.choices ?? []).flatMap((choice) => [choice.label, choice.id]));
931
- return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0);
932
- }
933
- function pendingHasMatchingChoice(pending, normalizedPrompt) {
934
- return pendingChoiceLabels(pending).includes(normalizedPrompt);
935
- }
936
- /**
937
- * Which outstanding questions this prompt plausibly answers.
938
- *
939
- * A unique closed-choice match is enough. A short confirmation such as "done"
940
- * matches only when exactly one question is outstanding. Long unrelated
941
- * prompts leave every question open for the reply window.
942
- */
943
- export function pendingAnsweredByPrompt(prompt, pending) {
944
- const normalized = normalizePrompt(prompt ?? '');
945
- if (normalized.length === 0 || pending.length === 0)
946
- return [];
947
- const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized));
948
- if (labelled.length === 1)
949
- return labelled;
950
- if (labelled.length > 1)
951
- return [];
952
- if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized))
953
- return [...pending];
954
- return [];
955
- }
956
- /**
957
- * Send every parked retirement, and forget the ones that landed. A failure
958
- * leaves the entry in place for the next hook rather than losing it, which is
959
- * the whole reason the queue exists.
960
- */
961
- export async function drainRetirements(ctx, sessionId, env) {
962
- const queue = readSessionState(sessionId, env).retiring ?? [];
963
- if (queue.length === 0)
964
- return [];
965
- const retired = [];
966
- for (const entry of queue) {
967
- const proven = await proveRetirement(ctx, entry.request_id);
968
- if (proven) {
969
- retired.push(entry.request_id);
970
- // Persist each success before touching the next entry. A later corrupt
971
- // record or interrupted process must not resurrect work already done.
972
- updateSessionState(sessionId, env, (current) => ({
973
- ...current,
974
- retiring: (current.retiring ?? []).filter((candidate) => candidate.request_id !== entry.request_id),
975
- }));
976
- }
977
- }
978
- return retired;
979
- }
980
- function orphanQueuePath(env) {
981
- return path.join(stateDir(env), 'retire-queue.json');
982
- }
983
- function readOrphanQueue(env) {
984
- const file = orphanQueuePath(env);
985
- if (!existsSync(file))
986
- return [];
987
- try {
988
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
989
- return Array.isArray(parsed) ? parsed : [];
990
- }
991
- catch {
992
- // Same stance as session state: corruption fails closed to "nothing queued".
993
- return [];
994
- }
995
- }
996
- function writeOrphanQueueUnlocked(file, queue) {
997
- atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`);
998
- }
999
- function updateOrphanQueue(env, update) {
1000
- const file = orphanQueuePath(env);
1001
- return withFileLock(`${file}.lock`, () => {
1002
- const next = update(readOrphanQueue(env));
1003
- writeOrphanQueueUnlocked(file, next);
1004
- return next;
1005
- });
1006
- }
1007
- /** Move retirements into the global queue; deduped so a retry costs nothing. */
1008
- export function orphanRetirements(env, entries, now) {
1009
- if (entries.length === 0)
1010
- return;
1011
- updateOrphanQueue(env, (queue) => {
1012
- const known = new Set(queue.map((entry) => entry.request_id));
1013
- const added = entries
1014
- .filter((entry) => !known.has(entry.request_id))
1015
- .map((entry) => ({ ...entry, enqueued_at: now }));
1016
- return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP);
1017
- });
1018
- }
1019
- /**
1020
- * Retire everything a dead session left behind. Failures stay queued for the
1021
- * next holder of a client; entries past the TTL are dropped as already dead.
1022
- */
1023
- export async function drainOrphanRetirements(ctx, env, now) {
1024
- const queue = readOrphanQueue(env);
1025
- if (queue.length === 0)
1026
- return [];
1027
- const done = [];
1028
- for (const entry of queue) {
1029
- const age = now - entry.enqueued_at;
1030
- // A negative age is a clock jump, not a fresh entry; retiring is idempotent
1031
- // and cheap, so treat it as due rather than letting it linger for ever.
1032
- if (age > ORPHAN_TTL_MS) {
1033
- done.push(entry.request_id);
1034
- updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
1035
- continue;
1036
- }
1037
- const proven = await proveRetirement(ctx, entry.request_id);
1038
- if (proven) {
1039
- done.push(entry.request_id);
1040
- updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
1041
- }
1042
- }
1043
- return done;
1044
- }
1045
242
  const LATE_STOP_POLL_SECONDS = 4;
1046
243
  const LATE_PROMPT_POLL_SECONDS = 3;
1047
244
  /**
@@ -1146,133 +343,6 @@ async function pollPendingReplies(ctx, live, timeoutSeconds) {
1146
343
  }
1147
344
  return { answered, transientTrouble, permanentFailures };
1148
345
  }
1149
- /** The registered-question queue. Anything but the current shape reads as empty. */
1150
- function pendingList(state) {
1151
- return Array.isArray(state.pending) ? state.pending : [];
1152
- }
1153
- const QUESTION_HISTORY_CAP = 50;
1154
- /** Keep a bounded, content-free map from local identity to its terminal state. */
1155
- function rememberQuestionState(current, question, state) {
1156
- if (question.question_id === undefined)
1157
- return current;
1158
- const existing = (current.question_history ?? []).find((entry) => entry.question_id === question.question_id);
1159
- const terminalState = existing?.state === 'withdrawn' || existing?.state === 'answered'
1160
- ? existing.state
1161
- : state === 'answered'
1162
- ? 'answered'
1163
- : state;
1164
- const requestId = question.request_id ?? existing?.request_id;
1165
- const frozenRequestId = requestId === undefined
1166
- ? question.submission?.request_id ?? existing?.frozen_request_id
1167
- : undefined;
1168
- const record = {
1169
- question_id: question.question_id,
1170
- state: terminalState,
1171
- ...(requestId === undefined ? {} : { request_id: requestId }),
1172
- ...(frozenRequestId === undefined ? {} : { frozen_request_id: frozenRequestId }),
1173
- };
1174
- const history = [
1175
- ...(current.question_history ?? []).filter((entry) => entry.question_id !== question.question_id),
1176
- record,
1177
- ].slice(-QUESTION_HISTORY_CAP);
1178
- return { ...current, question_history: history };
1179
- }
1180
- function pendingQuestionView(entry) {
1181
- if (entry.question_id === undefined)
1182
- return null;
1183
- if (entry.request_id !== undefined) {
1184
- return {
1185
- question_id: entry.question_id,
1186
- state: 'live',
1187
- submitted: true,
1188
- request_id: entry.request_id,
1189
- frozen_request_id: null,
1190
- };
1191
- }
1192
- if (entry.submission !== undefined) {
1193
- return {
1194
- question_id: entry.question_id,
1195
- state: 'frozen',
1196
- submitted: null,
1197
- request_id: null,
1198
- frozen_request_id: entry.submission.request_id,
1199
- };
1200
- }
1201
- return {
1202
- question_id: entry.question_id,
1203
- state: 'local',
1204
- submitted: false,
1205
- request_id: null,
1206
- frozen_request_id: null,
1207
- };
1208
- }
1209
- /** Read one stable local question identity without minting or submitting anything. */
1210
- export function inspectQuestionState(questionId, env) {
1211
- const owner = findOwningSession(questionId, env);
1212
- if (owner.ambiguous || owner.sessionId === null) {
1213
- return { found: false, ambiguous: owner.ambiguous };
1214
- }
1215
- const state = readSessionState(owner.sessionId, env);
1216
- const pending = pendingList(state).find((entry) => entry.question_id === questionId);
1217
- const pendingView = pending === undefined ? null : pendingQuestionView(pending);
1218
- if (pendingView !== null) {
1219
- return { found: true, session_id: owner.sessionId, question: pendingView };
1220
- }
1221
- const accepted = state.accepted?.answers.find(({ pending: entry }) => entry.question_id === questionId)?.pending;
1222
- if (accepted?.question_id !== undefined) {
1223
- return {
1224
- found: true,
1225
- session_id: owner.sessionId,
1226
- question: {
1227
- question_id: accepted.question_id,
1228
- state: 'answered',
1229
- submitted: true,
1230
- request_id: accepted.request_id ?? null,
1231
- frozen_request_id: null,
1232
- },
1233
- };
1234
- }
1235
- const retiring = (state.retiring ?? []).find((entry) => entry.question_id === questionId);
1236
- if (retiring?.question_id !== undefined) {
1237
- return {
1238
- found: true,
1239
- session_id: owner.sessionId,
1240
- question: {
1241
- question_id: retiring.question_id,
1242
- state: retiring.state === 'answered' ? 'answered' : 'retired',
1243
- submitted: true,
1244
- request_id: retiring.request_id,
1245
- frozen_request_id: null,
1246
- },
1247
- };
1248
- }
1249
- const history = (state.question_history ?? []).find((entry) => entry.question_id === questionId);
1250
- if (history !== undefined) {
1251
- return {
1252
- found: true,
1253
- session_id: owner.sessionId,
1254
- question: {
1255
- question_id: history.question_id,
1256
- state: history.state,
1257
- submitted: history.request_id !== undefined
1258
- ? true
1259
- : history.frozen_request_id !== undefined
1260
- ? null
1261
- : false,
1262
- request_id: history.request_id ?? null,
1263
- frozen_request_id: history.frozen_request_id ?? null,
1264
- },
1265
- };
1266
- }
1267
- return { found: false, ambiguous: false };
1268
- }
1269
- /** Registration identity — the convention every racing writer compares by. */
1270
- function isSamePending(a, b) {
1271
- if (a.question_id !== undefined || b.question_id !== undefined) {
1272
- return a.question_id !== undefined && a.question_id === b.question_id;
1273
- }
1274
- return a.question === b.question && a.asked_at === b.asked_at;
1275
- }
1276
346
  /**
1277
347
  * The frozen draft itself will never be accepted. Replaying it forever hides a
1278
348
  * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
@@ -1281,394 +351,6 @@ function isSamePending(a, b) {
1281
351
  function isTerminalDraftRejection(err) {
1282
352
  return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
1283
353
  }
1284
- export function dropPendingQuestion(sessionId, env, entry, terminalState = 'retired') {
1285
- updateSessionState(sessionId, env, (current) => {
1286
- const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
1287
- const next = { ...current };
1288
- if (pending.length > 0)
1289
- next.pending = pending;
1290
- else
1291
- delete next.pending;
1292
- return rememberQuestionState(next, entry, terminalState);
1293
- });
1294
- }
1295
- /**
1296
- * Drop registrations that never reached a device, so a later Stop cannot push
1297
- * them. A retirement push would be noise: there is nothing on any device to
1298
- * withdraw. Returns the withdrawn entries in registration order.
1299
- */
1300
- export function withdrawUnpushedQuestions(sessionId, env) {
1301
- let withdrawn = [];
1302
- updateSessionState(sessionId, env, (current) => {
1303
- const pending = pendingList(current);
1304
- withdrawn = pending.filter((entry) => entry.request_id === undefined);
1305
- if (withdrawn.length === 0)
1306
- return current;
1307
- const remaining = pending.filter((entry) => entry.request_id !== undefined);
1308
- const next = { ...current };
1309
- if (remaining.length > 0)
1310
- next.pending = remaining;
1311
- else
1312
- delete next.pending;
1313
- return withdrawn.reduce((remembered, entry) => rememberQuestionState(remembered, entry, 'withdrawn'), next);
1314
- });
1315
- return withdrawn;
1316
- }
1317
- function clearFrozenSubmission(sessionId, env, entry) {
1318
- updateSessionState(sessionId, env, (current) => {
1319
- const list = pendingList(current);
1320
- const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1321
- if (index < 0)
1322
- return current;
1323
- const next = [...list];
1324
- const copy = { ...next[index] };
1325
- delete copy.submission;
1326
- next[index] = copy;
1327
- return { ...current, pending: next };
1328
- });
1329
- }
1330
- /** The question set this pending record pushes, however it was registered. */
1331
- function pendingQuestions(pending) {
1332
- return pending.questions ?? [{ id: 'q1', text: pending.question }];
1333
- }
1334
- /** Did any question in the set offer choices? Decides how replies combine. */
1335
- function pendingHasChoices(pending) {
1336
- return pendingQuestions(pending).some((question) => question.choices !== undefined);
1337
- }
1338
- /**
1339
- * The answer as the agent should read it. For a question with choices the
1340
- * latest reply IS the answer — an earlier conflicting one was corrected by
1341
- * it. Free-text answers can arrive in parts, and every part reaches the
1342
- * agent in the order it was written, so it can tell expansion from
1343
- * correction itself.
1344
- *
1345
- * The continuation repeats only identity the agent actually registered and
1346
- * the answer the server accepted. It never invents trust, urgency, permission,
1347
- * or approval claims for the transport to assert.
1348
- */
1349
- function answerContext(answered) {
1350
- const { pending, replies } = answered;
1351
- const latest = replies.at(-1);
1352
- if (latest === undefined)
1353
- return 'Notifai — no answer was recorded.';
1354
- const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))];
1355
- const identity = answeredQuestionIds.length === 0
1356
- ? ''
1357
- : `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `;
1358
- const question = `question ${JSON.stringify(pending.question)}`;
1359
- if (replies.length === 1 || pendingHasChoices(pending)) {
1360
- return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`;
1361
- }
1362
- const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ');
1363
- return (`Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
1364
- `in the order written: ${parts}. Later parts extend or correct earlier ones.`);
1365
- }
1366
- /**
1367
- * Every answer that has arrived, as one message. Several registered questions
1368
- * may resolve in one hook pass; the agent reads them together, each answer
1369
- * tied to the question that asked it, with a truthful note about anything
1370
- * still waiting.
1371
- */
1372
- function acknowledgementCommand(requestId, textRequired = true) {
1373
- return textRequired
1374
- ? `notifai acknowledge ${requestId} --text <text>`
1375
- : `notifai acknowledge ${requestId}`;
1376
- }
1377
- /**
1378
- * The acknowledgement is owed either way; only its text is conditional. So the
1379
- * instruction never says "you may skip this" — it says what to run.
1380
- */
1381
- function acknowledgementDemand(textRequired) {
1382
- return textRequired
1383
- ? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
1384
- : ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words';
1385
- }
1386
- function acknowledgementContext(answered) {
1387
- const due = answered.filter((entry) => entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined);
1388
- if (due.length === 0) {
1389
- return ' Agent Acknowledgement is not required for the answered request(s).';
1390
- }
1391
- if (due.length === 1) {
1392
- const entry = due[0];
1393
- const requestId = entry.pending.request_id;
1394
- const textRequired = entry.agent_acknowledgement_text_required !== false;
1395
- return (` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
1396
- `\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`);
1397
- }
1398
- const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false);
1399
- const commands = due
1400
- .map((entry) => {
1401
- const requestId = entry.pending.request_id;
1402
- return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``;
1403
- })
1404
- .join('\n');
1405
- return (` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below${acknowledgementDemand(anyTextRequired)}:\n` +
1406
- commands);
1407
- }
1408
- function answersContext(answered, remaining) {
1409
- const tail = remaining > 0
1410
- ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1411
- : '';
1412
- const guidance = acknowledgementContext(answered);
1413
- if (answered.length === 1) {
1414
- return answerContext(answered[0]) + tail + guidance;
1415
- }
1416
- const lines = answered.map(({ pending, replies }) => {
1417
- const latest = replies.at(-1);
1418
- const answer = replies.length === 1 || pendingHasChoices(pending)
1419
- ? JSON.stringify(latest.text)
1420
- : `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`;
1421
- const ids = [...new Set(latest.answers.map((entry) => entry.question_id))];
1422
- const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `;
1423
- return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`;
1424
- });
1425
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`;
1426
- }
1427
- function stopAnswerOutput(context) {
1428
- return JSON.stringify({
1429
- decision: 'block',
1430
- reason: context,
1431
- });
1432
- }
1433
- /**
1434
- * Close, truthfully retire, and forget every answered pending question in one
1435
- * state write, without dropping any other retirement debt or any question
1436
- * still waiting. Stop answers also open one bounded continuation generation;
1437
- * UserPromptSubmit answers ride the user's new turn.
1438
- */
1439
- function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
1440
- const accepted = {
1441
- answers: answered,
1442
- remaining,
1443
- recorded_at: ctx.now(),
1444
- };
1445
- updateSessionState(sessionId, ctx.env, (current) => {
1446
- const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
1447
- const acknowledgementDue = [...(current.acknowledgement_due ?? [])];
1448
- for (const answer of answered) {
1449
- const requestId = answer.pending.request_id;
1450
- if (answer.agent_acknowledgement_required === true &&
1451
- requestId !== undefined &&
1452
- !acknowledgementDue.some((entry) => entry.request_id === requestId)) {
1453
- acknowledgementDue.push({
1454
- request_id: requestId,
1455
- recorded_at: accepted.recorded_at,
1456
- text_required: answer.agent_acknowledgement_text_required !== false,
1457
- });
1458
- }
1459
- }
1460
- const next = {
1461
- ...current,
1462
- accepted,
1463
- };
1464
- if (pendingRemaining.length > 0)
1465
- next.pending = pendingRemaining;
1466
- else
1467
- delete next.pending;
1468
- if (acknowledgementDue.length > 0)
1469
- next.acknowledgement_due = acknowledgementDue;
1470
- else
1471
- delete next.acknowledgement_due;
1472
- return answered.reduce((remembered, answer) => rememberQuestionState(remembered, answer.pending, 'answered'), next);
1473
- });
1474
- return accepted;
1475
- }
1476
- /** Amend the live accepted record in place, without resurrecting a settled one. */
1477
- function amendAcceptedAnswers(ctx, sessionId, amend) {
1478
- updateSessionState(sessionId, ctx.env, (current) => current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) });
1479
- }
1480
- /**
1481
- * Finish a route write that linearized before SessionEnd.
1482
- *
1483
- * Ordinary state writers stop at the ended marker. This one narrow completion
1484
- * is allowed through because the journal's own `delivery_committed_at` proves
1485
- * the irreversible write won the ordering first; without the matching finish,
1486
- * a later SessionStart would replay a write that already reached the harness.
1487
- */
1488
- function finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute) {
1489
- const file = sessionStatePath(sessionId, ctx.env);
1490
- withFileLock(`${file}.lock`, () => {
1491
- const current = readSessionState(sessionId, ctx.env);
1492
- if (current.accepted?.recorded_at !== accepted.recorded_at ||
1493
- current.accepted.delivery_committed_at === undefined) {
1494
- return;
1495
- }
1496
- writeSessionStateUnlocked(file, sessionId, {
1497
- ...current,
1498
- accepted: {
1499
- ...current.accepted,
1500
- delivered_at: ctx.now(),
1501
- delivered_route: deliveredRoute,
1502
- },
1503
- });
1504
- });
1505
- }
1506
- export function clearAcknowledgementObligation(sessionId, env, requestId) {
1507
- let cleared = false;
1508
- updateSessionState(sessionId, env, (current) => {
1509
- const due = current.acknowledgement_due ?? [];
1510
- const remaining = due.filter((entry) => entry.request_id !== requestId);
1511
- cleared = remaining.length !== due.length;
1512
- if (!cleared)
1513
- return current;
1514
- const next = { ...current };
1515
- if (remaining.length > 0)
1516
- next.acknowledgement_due = remaining;
1517
- else
1518
- delete next.acknowledgement_due;
1519
- return next;
1520
- });
1521
- return cleared;
1522
- }
1523
- function acknowledgementBlockContext(due) {
1524
- const commands = due
1525
- .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``)
1526
- .join('\n');
1527
- const anyTextRequired = due.some((entry) => entry.text_required !== false);
1528
- return (`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
1529
- `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`);
1530
- }
1531
- /**
1532
- * How many turns in a row a session may be held waiting for an acknowledgement.
1533
- *
1534
- * The gate is the one place hooks deliberately break the fail-open rule, and it
1535
- * had no bound at all: an agent that could not or would not acknowledge — or a
1536
- * server that stayed unreachable, since an error here counts as unresolved —
1537
- * held every turn of that session for ever. Blocking a user's agent
1538
- * indefinitely does not get them an acknowledgement; it costs them the agent as
1539
- * well as the acknowledgement.
1540
- */
1541
- const MAX_ACKNOWLEDGEMENT_BLOCKS = 3;
1542
- /**
1543
- * Hold the turn for an outstanding acknowledgement, or give up and let it
1544
- * through once holding has stopped being worth its cost.
1545
- *
1546
- * Returns the outcome that blocks the turn, or `null` to carry on.
1547
- */
1548
- function holdForAcknowledgement(ctx, sessionId, due, notes) {
1549
- if (due.length === 0)
1550
- return null;
1551
- const requestIds = due.map((entry) => entry.request_id);
1552
- const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1;
1553
- if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
1554
- // Drop the obligation with the reason recorded. The answer was already
1555
- // delivered; what is lost is the agent's receipt for it, and the log is
1556
- // where that loss stays visible.
1557
- for (const requestId of requestIds)
1558
- clearAcknowledgementObligation(sessionId, ctx.env, requestId);
1559
- resetAcknowledgementBlocks(sessionId, ctx.env);
1560
- gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
1561
- request_ids: requestIds,
1562
- blocks: blocks - 1,
1563
- limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
1564
- });
1565
- notes.push(`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`);
1566
- return null;
1567
- }
1568
- updateSessionState(sessionId, ctx.env, (current) => ({
1569
- ...current,
1570
- acknowledgement_blocks: blocks,
1571
- }));
1572
- gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks });
1573
- return {
1574
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1575
- notes,
1576
- log: { stage: 'acknowledgement-required', request_ids: requestIds },
1577
- };
1578
- }
1579
- /** A turn that was not held resets the streak; only consecutive holds count. */
1580
- function resetAcknowledgementBlocks(sessionId, env) {
1581
- updateSessionState(sessionId, env, (current) => {
1582
- if (current.acknowledgement_blocks === undefined)
1583
- return current;
1584
- const next = { ...current };
1585
- delete next.acknowledgement_blocks;
1586
- return next;
1587
- });
1588
- }
1589
- async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
1590
- const unresolved = [];
1591
- for (const obligation of due) {
1592
- try {
1593
- const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
1594
- waitSeconds: 0,
1595
- });
1596
- if (snapshot.agent_acknowledgement_required === false ||
1597
- snapshot.agent_acknowledgement !== null) {
1598
- clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id);
1599
- }
1600
- else {
1601
- unresolved.push(obligation);
1602
- }
1603
- }
1604
- catch (err) {
1605
- unresolved.push(obligation);
1606
- ctx.log?.error('hook.gate', {
1607
- verdict: 'held',
1608
- reason: 'acknowledgement-required',
1609
- stage: 'reconcile-failed',
1610
- request_id: obligation.request_id,
1611
- message: err instanceof Error ? err.message : String(err),
1612
- });
1613
- }
1614
- }
1615
- return unresolved;
1616
- }
1617
- /**
1618
- * Retire the answered questions and close the journal.
1619
- *
1620
- * Called once an answer's delivery is acknowledged — by the route's own write,
1621
- * or by the successor Stop of a blocking continuation. The continuation counter
1622
- * grows with each settled generation, so the cap that bounds chained
1623
- * answer-to-question loops is a real count rather than a constant 1.
1624
- */
1625
- function settleAcceptedAnswers(ctx, sessionId, accepted, cwd) {
1626
- const retirements = accepted.answers
1627
- .map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
1628
- .filter((entry) => entry !== null);
1629
- updateSessionState(sessionId, ctx.env, (current) => {
1630
- const retiring = [...(current.retiring ?? [])];
1631
- for (const retirement of retirements) {
1632
- const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id);
1633
- if (existing < 0)
1634
- retiring.push(retirement);
1635
- else
1636
- retiring[existing] = retirement;
1637
- }
1638
- const next = { ...current, retiring };
1639
- delete next.accepted;
1640
- next.continuation = {
1641
- answered_at: accepted.recorded_at,
1642
- count: (current.continuation?.count ?? 0) + 1,
1643
- };
1644
- return next;
1645
- });
1646
- }
1647
- async function retirePendings(ctx, envelope, sessionId, entries, state) {
1648
- updateSessionState(sessionId, ctx.env, (current) => {
1649
- const retiring = [...(current.retiring ?? [])];
1650
- for (const entry of entries) {
1651
- const retirement = retiringQuestion(entry, state, envelope.cwd);
1652
- if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1653
- retiring.push(retirement);
1654
- }
1655
- }
1656
- const pending = pendingList(current).filter((candidate) => !entries.some((entry) => isSamePending(candidate, entry)));
1657
- const next = { ...current };
1658
- if (pending.length > 0)
1659
- next.pending = pending;
1660
- else
1661
- delete next.pending;
1662
- if (retiring.length > 0)
1663
- next.retiring = retiring;
1664
- else
1665
- delete next.retiring;
1666
- return entries.reduce((remembered, entry) => rememberQuestionState(remembered, entry, state === 'answered' ? 'answered' : 'retired'), next);
1667
- });
1668
- // Network retirement is deliberately deferred to a later no-question hook.
1669
- // The state write above is the durable operation; blocking here creates a
1670
- // crash window before an answer can reach harness stdout.
1671
- }
1672
354
  // ---------------------------------------------------------------------------
1673
355
  // UserPromptSubmit — the user is at the keyboard
1674
356
  // ---------------------------------------------------------------------------
@@ -1692,9 +374,21 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1692
374
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1693
375
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
1694
376
  }
1695
- notes.push(state.accepted.delivered_at === undefined
1696
- ? 'a device answer is safely journaled; the next Stop will deliver it'
1697
- : 'a device answer has already been delivered; the next Stop will close it out');
377
+ const stdout = userPromptContextOutput(ctx.harness, answersContext(state.accepted.answers, state.accepted.remaining));
378
+ if (stdout !== undefined) {
379
+ notes.push('the journaled device answer was added to the user\'s new turn');
380
+ return {
381
+ stdout,
382
+ decided: false,
383
+ notes,
384
+ log: {
385
+ stage: 'context-added',
386
+ route: 'user-prompt-submit',
387
+ request_ids: state.accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]),
388
+ },
389
+ };
390
+ }
391
+ notes.push('a device answer is safely journaled; the next Stop will deliver it');
1698
392
  return { notes };
1699
393
  }
1700
394
  const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
@@ -1789,12 +483,27 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1789
483
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
1790
484
  }
1791
485
  if (lateAnswers.length > 0) {
1792
- // UserPromptSubmit has no later hook field proving its stdout reached the
1793
- // model. Keep the journal and let the next Stop use its acknowledged
1794
- // continuation channel instead of recreating the crash-before-stdout gap.
1795
- notes.push('the late device answer will continue the agent at this turn’s Stop');
486
+ // UserPromptSubmit can add context to the turn already beginning. Keep the
487
+ // accepted-answer journal until the agent acknowledges: if this process or
488
+ // host dies around stdout, a later prompt or Stop safely replays it.
489
+ const stdout = userPromptContextOutput(ctx.harness, answersContext(lateAnswers, 0));
490
+ if (stdout !== undefined)
491
+ notes.push('the late device answer was added to the user\'s new turn');
492
+ else
493
+ notes.push('the late device answer will continue the agent at this turn’s Stop');
1796
494
  return {
1797
495
  notes,
496
+ ...(stdout === undefined
497
+ ? {}
498
+ : {
499
+ stdout,
500
+ decided: false,
501
+ log: {
502
+ stage: 'context-added',
503
+ route: 'user-prompt-submit',
504
+ request_ids: lateAnswers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]),
505
+ },
506
+ }),
1798
507
  settlementRequired: pendingList(updated).some((entry) => entry.request_id === undefined),
1799
508
  };
1800
509
  }
@@ -1923,7 +632,13 @@ export async function runEscalationWaiter(ctx, options) {
1923
632
  // on the answer — delivery is not consumption — but nothing later can
1924
633
  // prove more, and an answer redelivered without end is strictly worse
1925
634
  // than one settled on delivery. So a recorded delivery settles.
1926
- const deliveryProven = accepted.delivered_at !== undefined || envelope.stop_hook_active === true;
635
+ const acknowledgementProvesDelivery = accepted.answers.length > 0 &&
636
+ accepted.answers.every(({ pending, agent_acknowledgement_required }) => agent_acknowledgement_required === true &&
637
+ pending.request_id !== undefined &&
638
+ !(state.acknowledgement_due ?? []).some((entry) => entry.request_id === pending.request_id));
639
+ const deliveryProven = accepted.delivered_at !== undefined ||
640
+ envelope.stop_hook_active === true ||
641
+ acknowledgementProvesDelivery;
1927
642
  if (!deliveryProven) {
1928
643
  for (const answer of accepted.answers)
1929
644
  reportAnswer(ctx, notes, answer, true);
@@ -2288,14 +1003,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2288
1003
  const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
2289
1004
  let intent = entry.submission;
2290
1005
  if (intent === undefined) {
2291
- if (entry.body === undefined) {
2292
- notes.push('registered question has no canonical body; register it again with this CLI');
2293
- continue;
2294
- }
2295
1006
  const prepared = await prepareQuestionSubmission(ctx, {
2296
- // Type and Project have their own fields; the title is only substance.
2297
- title: questions[0].text,
2298
- body: entry.body,
1007
+ summary: entry.summary,
1008
+ ...(entry.body !== undefined ? { body: entry.body } : {}),
2299
1009
  questions,
2300
1010
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2301
1011
  ...(entry.project !== undefined ? { project: entry.project } : {}),
@@ -2369,14 +1079,9 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
2369
1079
  if (entry.submission !== undefined) {
2370
1080
  notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
2371
1081
  clearFrozenSubmission(sessionId, ctx.env, entry);
2372
- if (entry.body === undefined) {
2373
- dropPendingQuestion(sessionId, ctx.env, entry);
2374
- notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it');
2375
- continue;
2376
- }
2377
1082
  const reminted = await prepareQuestionSubmission(ctx, {
2378
- title: questions[0].text,
2379
- body: entry.body,
1083
+ summary: entry.summary,
1084
+ ...(entry.body !== undefined ? { body: entry.body } : {}),
2380
1085
  questions,
2381
1086
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2382
1087
  ...(entry.project !== undefined ? { project: entry.project } : {}),
@@ -2873,7 +1578,4 @@ export function parseHookInput(raw) {
2873
1578
  return {};
2874
1579
  }
2875
1580
  }
2876
- export function resolveHookConfig(cwd, env, sessionId) {
2877
- return loadConfig({ cwd, env, sessionId });
2878
- }
2879
- //# sourceMappingURL=hooks.js.map
1581
+ //# sourceMappingURL=hook-lifecycle.js.map