@raidiant/notifai 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
package/dist/hooks.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { ApiCallError, isRetryableReplyPollError, } from './client.js';
4
5
  import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
5
6
  import { buildDraft } from './send.js';
6
7
  import { atomicWriteFileSync } from './atomic-file.js';
8
+ import { withFileLock } from './file-lock.js';
9
+ import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING } from './harnesses.js';
7
10
  /**
8
11
  * Past this, the question's reply window (3600s) has long expired server-side
9
12
  * and the companion shows it as dead on next open anyway; pushing a retirement
@@ -13,14 +16,20 @@ import { atomicWriteFileSync } from './atomic-file.js';
13
16
  const ORPHAN_TTL_MS = 24 * 3600 * 1000;
14
17
  /** More orphans than this means something is looping; keep the newest. */
15
18
  const ORPHAN_QUEUE_CAP = 50;
19
+ function summarizeRequestIds(entries) {
20
+ const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
21
+ return { ids, display: ids.join(', ') };
22
+ }
16
23
  /** How often the grace window rechecks whether the user has come back. */
17
24
  const GRACE_POLL_MS = 5_000;
18
25
  /**
19
- * Total seconds a Stop hook may spend blocking. Both harnesses kill a command
20
- * hook at 600s, and a killed hook loses an answer the user has already given,
21
- * so the grace window yields to the reply wait rather than the other way round.
26
+ * Total seconds the complete Stop invocation may own. Generated adapters grant
27
+ * another minute of host headroom, because a killed hook can lose an answer
28
+ * the user has already given. Grace and reply waiting share this budget while
29
+ * the reply retains a small actionable window and finalization reserve.
22
30
  */
23
- const STOP_BUDGET_SECONDS = 480;
31
+ const STOP_BUDGET_SECONDS = HOOK_TIMING.totalSeconds;
32
+ const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds;
24
33
  /**
25
34
  * The terminal-first wait: the question sits in the terminal for
26
35
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -42,8 +51,8 @@ const STOP_BUDGET_SECONDS = 480;
42
51
  */
43
52
  export async function awaitGrace(ctx, askedAt) {
44
53
  const threshold = ctx.config.away_after_seconds.value;
45
- // Never let the grace window eat the reply wait's share of the hook budget.
46
- const graceSeconds = Math.min(ctx.config.ask_grace_seconds.value, Math.max(0, STOP_BUDGET_SECONDS - ctx.config.hook_reply_timeout_seconds.value));
54
+ // Keep a real device-answer window even when a user selects maximum grace.
55
+ const graceSeconds = Math.min(ctx.config.ask_grace_seconds.value, HOOK_TIMING.maxGraceSeconds);
47
56
  // `asked_at` is wall-clock too, and the same jumps apply: a stamp from the
48
57
  // future would wait far past the hook's budget, one from the distant past
49
58
  // would skip the terminal-first window the user asked for entirely. Anything
@@ -105,63 +114,6 @@ function updateSessionState(sessionId, env, update) {
105
114
  return next;
106
115
  });
107
116
  }
108
- /**
109
- * Serialize a short read-modify-write without ever exposing partial JSON.
110
- * A crashed holder is recoverable after 30 seconds; releases compare their
111
- * random token under the same cooperating lock discipline before unlinking.
112
- */
113
- const FILE_LOCK_STALE_MS = 30_000;
114
- const FILE_LOCK_WAIT_MS = 1_000;
115
- const FILE_LOCK_POLL_MS = 5;
116
- const lockSleep = new Int32Array(new SharedArrayBuffer(4));
117
- function withFileLock(file, action) {
118
- mkdirSync(path.dirname(file), { recursive: true });
119
- const token = randomBytes(12).toString('base64url');
120
- const deadline = Date.now() + FILE_LOCK_WAIT_MS;
121
- for (;;) {
122
- try {
123
- const handle = openSync(file, 'wx', 0o600);
124
- try {
125
- writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`);
126
- }
127
- finally {
128
- closeSync(handle);
129
- }
130
- break;
131
- }
132
- catch (err) {
133
- if (err.code !== 'EEXIST')
134
- throw err;
135
- let stale = false;
136
- try {
137
- const held = JSON.parse(readFileSync(file, 'utf8'));
138
- stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS;
139
- }
140
- catch {
141
- stale = true;
142
- }
143
- if (stale)
144
- rmSync(file, { force: true });
145
- else if (Date.now() >= deadline)
146
- throw new Error(`timed out waiting for state lock ${file}`);
147
- else
148
- Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS);
149
- }
150
- }
151
- try {
152
- return action();
153
- }
154
- finally {
155
- try {
156
- const held = JSON.parse(readFileSync(file, 'utf8'));
157
- if (held.token === token)
158
- rmSync(file, { force: true });
159
- }
160
- catch {
161
- // A replaced or externally removed lock is no longer ours to release.
162
- }
163
- }
164
- }
165
117
  /**
166
118
  * Session state a crashed harness left behind.
167
119
  *
@@ -223,8 +175,9 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
223
175
  * Exclusive create is atomic on POSIX, so exactly one process gets the claim
224
176
  * and the other steps aside. A short guard serializes stale replacement with
225
177
  * contenders and releases; random ownership tokens keep an old holder from
226
- * unlinking its replacement. A claim older than the hook budget is a crashed
227
- * process rather than a live one and is broken, avoiding permanent suppression.
178
+ * unlinking its replacement. A live PID owns the claim regardless of age; a
179
+ * known-dead PID is recoverable immediately. Only legacy/corrupt claims with
180
+ * no trustworthy PID fall back to an age limit.
228
181
  */
229
182
  const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000;
230
183
  const heldClaims = new Map();
@@ -249,8 +202,13 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
249
202
  try {
250
203
  const held = JSON.parse(readFileSync(file, 'utf8'));
251
204
  const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY;
252
- if (age >= 0 && age < CLAIM_TTL_MS)
205
+ if (typeof held.pid === 'number') {
206
+ if (processIsAlive(held.pid))
207
+ return false;
208
+ }
209
+ else if (age >= 0 && age < CLAIM_TTL_MS) {
253
210
  return false;
211
+ }
254
212
  }
255
213
  catch {
256
214
  // Unreadable or corrupt: treat as abandoned.
@@ -274,6 +232,15 @@ export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleR
274
232
  rmSync(guard, { force: true });
275
233
  }
276
234
  }
235
+ function processIsAlive(pid) {
236
+ try {
237
+ process.kill(pid, 0);
238
+ return true;
239
+ }
240
+ catch (err) {
241
+ return err.code === 'EPERM';
242
+ }
243
+ }
277
244
  export function releaseQuestionPush(sessionId, env) {
278
245
  const file = claimPath(sessionId, env);
279
246
  const token = heldClaims.get(file);
@@ -332,13 +299,53 @@ function claimPath(sessionId, env) {
332
299
  * meantime, and keeps one runaway agent from writing megabytes per session.
333
300
  */
334
301
  const MAX_STORED_QUESTION_CHARS = 2000;
335
- /** Records which session is working in a directory, for `notifai ask`. */
302
+ /** Records every live session working in a directory, for `notifai ask`. */
336
303
  export function writeProjectSession(cwd, env, sessionId, now, harness) {
337
304
  const file = projectSessionPointerPath(cwd, env);
338
305
  withFileLock(`${file}.lock`, () => {
339
- atomicWriteFileSync(file, `${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`);
306
+ const existing = readStoredProjectSessionPointers(file).filter((entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000);
307
+ atomicWriteFileSync(file, `${JSON.stringify({
308
+ sessions: [
309
+ ...existing.map((entry) => ({
310
+ session_id: entry.sessionId,
311
+ updated_at: entry.updatedAt,
312
+ harness: entry.harness,
313
+ })),
314
+ { session_id: sessionId, updated_at: now, harness },
315
+ ],
316
+ })}\n`);
340
317
  });
341
318
  }
319
+ function readStoredProjectSessionPointers(file) {
320
+ if (!existsSync(file))
321
+ return [];
322
+ try {
323
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
324
+ if (!Array.isArray(parsed.sessions))
325
+ return [];
326
+ return parsed.sessions.flatMap((candidate) => {
327
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
328
+ return [];
329
+ const entry = candidate;
330
+ if (typeof entry.session_id !== 'string' || entry.session_id === '')
331
+ return [];
332
+ if (typeof entry.updated_at !== 'number')
333
+ return [];
334
+ if (!HARNESSES.includes(entry.harness))
335
+ return [];
336
+ return [
337
+ {
338
+ sessionId: entry.session_id,
339
+ updatedAt: entry.updated_at,
340
+ harness: entry.harness,
341
+ },
342
+ ];
343
+ });
344
+ }
345
+ catch {
346
+ return [];
347
+ }
348
+ }
342
349
  /**
343
350
  * Resolves the session working in `cwd`. Stale pointers are ignored rather than
344
351
  * trusted: an id left by a session that ended days ago would send the question
@@ -349,43 +356,65 @@ export function readProjectSession(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
349
356
  }
350
357
  export function readProjectSessionPointer(cwd, env, now, maxAgeMs = 24 * 3600 * 1000) {
351
358
  const file = projectSessionPointerPath(cwd, env);
352
- if (!existsSync(file))
359
+ const live = readStoredProjectSessionPointers(file)
360
+ .filter((entry) => now - entry.updatedAt <= maxAgeMs)
361
+ .filter((entry) => {
362
+ try {
363
+ // A pointer is routing evidence only while its session state still
364
+ // exists and parses. SessionEnd and explicit cleanup therefore
365
+ // invalidate it even when a crash prevented removal from this index.
366
+ const sessionFile = sessionStatePath(entry.sessionId, env);
367
+ if (!existsSync(sessionFile))
368
+ return false;
369
+ const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
370
+ return typeof state === 'object' && state !== null && !Array.isArray(state);
371
+ }
372
+ catch {
373
+ return false;
374
+ }
375
+ })
376
+ .sort((left, right) => right.updatedAt - left.updatedAt);
377
+ const latest = live[0];
378
+ return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness };
379
+ }
380
+ export function readMatchingProjectSessionPointer(cwd, env, now, sessionId, harness, maxAgeMs = 24 * 3600 * 1000) {
381
+ const file = projectSessionPointerPath(cwd, env);
382
+ const match = readStoredProjectSessionPointers(file).find((entry) => entry.sessionId === sessionId &&
383
+ entry.harness === harness &&
384
+ now - entry.updatedAt <= maxAgeMs);
385
+ if (match === undefined)
353
386
  return null;
354
387
  try {
355
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
356
- if (typeof parsed.session_id !== 'string' || parsed.session_id === '')
357
- return null;
358
- if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs)
359
- return null;
360
- if (!HOOK_HARNESSES.includes(parsed.harness))
361
- return null;
362
388
  // A pointer is routing evidence only while its session state still exists
363
389
  // and parses. SessionEnd and explicit cleanup therefore invalidate it even
364
390
  // when a crash prevented the pointer file itself from being removed.
365
- const sessionFile = sessionStatePath(parsed.session_id, env);
391
+ const sessionFile = sessionStatePath(match.sessionId, env);
366
392
  if (!existsSync(sessionFile))
367
393
  return null;
368
394
  const state = JSON.parse(readFileSync(sessionFile, 'utf8'));
369
395
  if (typeof state !== 'object' || state === null || Array.isArray(state))
370
396
  return null;
371
- return { sessionId: parsed.session_id, harness: parsed.harness };
397
+ return { sessionId: match.sessionId, harness: match.harness };
372
398
  }
373
399
  catch {
374
400
  return null;
375
401
  }
376
402
  }
377
- const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'];
378
403
  function clearMatchingProjectSession(cwd, env, sessionId) {
379
404
  const file = projectSessionPointerPath(cwd, env);
380
405
  withFileLock(`${file}.lock`, () => {
381
- try {
382
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
383
- if (parsed.session_id === sessionId)
384
- rmSync(file, { force: true });
385
- }
386
- catch {
387
- // Missing/corrupt is already not a live pointer.
406
+ const remaining = readStoredProjectSessionPointers(file).filter((entry) => entry.sessionId !== sessionId);
407
+ if (remaining.length === 0) {
408
+ rmSync(file, { force: true });
409
+ return;
388
410
  }
411
+ atomicWriteFileSync(file, `${JSON.stringify({
412
+ sessions: remaining.map((entry) => ({
413
+ session_id: entry.sessionId,
414
+ updated_at: entry.updatedAt,
415
+ harness: entry.harness,
416
+ })),
417
+ })}\n`);
389
418
  });
390
419
  }
391
420
  /**
@@ -438,6 +467,9 @@ export function isUserAway(state, config, now, idleSeconds) {
438
467
  * than about the user. Matches the project pointer's staleness horizon.
439
468
  */
440
469
  const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000;
470
+ function gate(ctx, verdict, reason, data = {}) {
471
+ ctx.log?.info('hook.gate', { verdict, reason, ...data });
472
+ }
441
473
  /** Re-check local presence at this cadence while a pushed question is waiting. */
442
474
  const REPLY_PRESENCE_POLL_SECONDS = 5;
443
475
  /**
@@ -455,7 +487,7 @@ const REPLY_PRESENCE_POLL_SECONDS = 5;
455
487
  * Put one registered question on the user's devices. Submission only — the
456
488
  * wait happens once, across every live question, in the caller.
457
489
  */
458
- async function submitQuestion(ctx, options) {
490
+ async function prepareQuestionSubmission(ctx, options) {
459
491
  const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
460
492
  // A draft carrying `reply` is rejected outright if it targets a device that
461
493
  // cannot answer, so resolve the healthy companion platforms explicitly.
@@ -478,8 +510,25 @@ async function submitQuestion(ctx, options) {
478
510
  });
479
511
  if (!build.ok)
480
512
  return { error: build.error };
481
- const receipt = await ctx.client.submit({ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft }, 0);
482
- return { requestId: receipt.request_id, collapseKey, devices: answerable };
513
+ return {
514
+ request_id: `req_${randomBytes(18).toString('base64url')}`,
515
+ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
516
+ collapse_key: collapseKey,
517
+ device_ids: answerable,
518
+ draft: build.draft,
519
+ owner_deadline_at: options.ownerDeadlineAt,
520
+ };
521
+ }
522
+ async function submitQuestion(ctx, intent) {
523
+ const receipt = await ctx.client.submit({
524
+ request_id: intent.request_id,
525
+ idempotency_key: intent.idempotency_key,
526
+ draft: intent.draft,
527
+ }, 0);
528
+ if (receipt.request_id !== intent.request_id) {
529
+ throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
530
+ }
531
+ return receipt;
483
532
  }
484
533
  /**
485
534
  * One blocking wait across every live question. Each round polls all of them
@@ -487,41 +536,68 @@ async function submitQuestion(ctx, options) {
487
536
  * first round that finds any reply returns everything found in that round, and
488
537
  * whatever was not answered stays registered.
489
538
  */
490
- async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds) {
539
+ async function waitForAnyReplyWhileAway(ctx, requestIds, timeoutSeconds, monitorPresence = true) {
491
540
  const deadline = ctx.now() + timeoutSeconds * 1000;
492
541
  let degraded = false;
493
542
  let firstPoll = true;
543
+ const permanentFailures = new Map();
494
544
  for (;;) {
495
- if (ctx.config.require_idle.value) {
545
+ if (monitorPresence && ctx.config.require_idle.value) {
496
546
  const idle = ctx.idleSeconds();
497
547
  if (idle !== null && idle < ctx.config.away_after_seconds.value) {
498
- return { byRequest: new Map(), degraded, userReturned: true };
548
+ return { byRequest: new Map(), degraded, userReturned: true, permanentFailures };
499
549
  }
500
550
  }
501
551
  const remainingMs = Math.max(0, deadline - ctx.now());
502
552
  if (!firstPoll && remainingMs === 0) {
503
- return { byRequest: new Map(), degraded, userReturned: false };
553
+ return { byRequest: new Map(), degraded, userReturned: false, permanentFailures };
504
554
  }
505
555
  firstPoll = false;
506
556
  const pollSeconds = Math.min(REPLY_PRESENCE_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
507
- const results = await Promise.all(requestIds.map(async (requestId) => {
557
+ const results = await Promise.all(requestIds
558
+ .filter((requestId) => !permanentFailures.has(requestId))
559
+ .map(async (requestId) => {
508
560
  try {
509
561
  return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) };
510
562
  }
511
- catch {
512
- return { requestId, replies: [], degraded: true };
563
+ catch (err) {
564
+ if (isRetryableReplyPollError(err)) {
565
+ return { requestId, replies: [], degraded: true };
566
+ }
567
+ return {
568
+ requestId,
569
+ replies: [],
570
+ degraded: false,
571
+ permanentFailure: replyPollFailure(err),
572
+ };
513
573
  }
514
574
  }));
515
575
  const byRequest = new Map();
516
576
  for (const result of results) {
517
577
  degraded ||= result.degraded === true;
578
+ if (result.permanentFailure !== undefined) {
579
+ permanentFailures.set(result.requestId, result.permanentFailure);
580
+ }
518
581
  if (result.replies.length > 0)
519
582
  byRequest.set(result.requestId, result.replies);
520
583
  }
521
- if (byRequest.size > 0)
522
- return { byRequest, degraded, userReturned: false };
584
+ if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
585
+ return { byRequest, degraded, userReturned: false, permanentFailures };
586
+ }
523
587
  }
524
588
  }
589
+ /** A non-retryable replies fault, with enough server identity to act on it. */
590
+ function replyPollFailure(err) {
591
+ return err instanceof ApiCallError
592
+ ? `${err.code} (HTTP ${err.status}): ${err.message}`
593
+ : String(err);
594
+ }
595
+ function permanentReplyFailureNote(failures) {
596
+ if (failures.size === 0)
597
+ return null;
598
+ const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ');
599
+ return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`;
600
+ }
525
601
  /**
526
602
  * Healthy companion devices that implement replies. Both the iOS app and the
527
603
  * macOS app register reply categories and submit answers directly.
@@ -552,15 +628,24 @@ function sessionLabel(ctx, envelope) {
552
628
  return explicit;
553
629
  return envelope.session_id;
554
630
  }
555
- /** Best effort: a question that outlives its hook is a nuisance, not a failure. */
556
- async function closeQuietly(ctx, requestId) {
631
+ /**
632
+ * Close is a server-side serialization fence: a successful response contains
633
+ * every reply that committed before the window closed. `null` is deliberately
634
+ * different from an empty reply set — it means ownership could not be proven
635
+ * final and the local record must stay recoverable.
636
+ */
637
+ async function finalizeReplies(ctx, requestId) {
557
638
  try {
558
- await ctx.client.closeReplies(requestId);
639
+ return await ctx.client.closeReplies(requestId);
559
640
  }
560
641
  catch {
561
- // The window expires on its own; nothing here is worth failing a hook for.
642
+ return null;
562
643
  }
563
644
  }
645
+ /** Best effort only for already-journaled retirement debt. */
646
+ async function closeQuietly(ctx, requestId) {
647
+ await finalizeReplies(ctx, requestId);
648
+ }
564
649
  /**
565
650
  * Retire the question on every device it reached. A state change is not news
566
651
  * (D-B): the retirement rides as a background state sync — no alert, no
@@ -759,7 +844,7 @@ const MAX_CONTINUATION_COUNT = 3;
759
844
  /** One bounded recovery poll for a question that outlived its original wait. */
760
845
  async function pollPendingReply(ctx, pending, timeoutSeconds) {
761
846
  if (pending.request_id === undefined) {
762
- return { reply: null, replies: [], degraded: false, failed: false };
847
+ return { reply: null, replies: [], degraded: false, permanentFailure: null };
763
848
  }
764
849
  try {
765
850
  const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
@@ -767,13 +852,39 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
767
852
  reply: result.replies.at(-1) ?? null,
768
853
  replies: result.replies,
769
854
  degraded: result.degraded === true,
770
- failed: false,
855
+ permanentFailure: null,
771
856
  };
772
857
  }
773
- catch {
774
- return { reply: null, replies: [], degraded: true, failed: true };
858
+ catch (err) {
859
+ return isRetryableReplyPollError(err)
860
+ ? { reply: null, replies: [], degraded: true, permanentFailure: null }
861
+ : { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) };
775
862
  }
776
863
  }
864
+ /** Close several windows concurrently without confusing failure with silence. */
865
+ async function finalizePendings(ctx, pending) {
866
+ return Promise.all(pending.map(async (entry) => ({
867
+ pending: entry,
868
+ response: await finalizeReplies(ctx, entry.request_id),
869
+ })));
870
+ }
871
+ /** Convert a fenced final reply set into the exact answer handed to the model. */
872
+ function finalizedAnswer(finalized) {
873
+ const replies = finalized.response?.replies ?? [];
874
+ const reply = replies.at(-1);
875
+ return reply === undefined ? null : { pending: finalized.pending, reply, replies };
876
+ }
877
+ /** Persist and surface one answer without letting the two representations drift. */
878
+ function reportAnswer(ctx, notes, answered, late) {
879
+ ctx.log?.info('hook.answer', {
880
+ answered: true,
881
+ ...(late ? { late: true } : {}),
882
+ request_id: answered.pending.request_id,
883
+ device: answered.reply.device_name,
884
+ text: answered.reply.text,
885
+ });
886
+ notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
887
+ }
777
888
  /**
778
889
  * One bounded recovery poll across every delivered question, concurrently —
779
890
  * N questions must not multiply the hook's latency budget by N.
@@ -781,15 +892,19 @@ async function pollPendingReply(ctx, pending, timeoutSeconds) {
781
892
  async function pollPendingReplies(ctx, live, timeoutSeconds) {
782
893
  const polls = await Promise.all(live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)));
783
894
  const answered = [];
784
- let troubled = false;
895
+ let transientTrouble = false;
896
+ const permanentFailures = new Map();
785
897
  for (const [index, poll] of polls.entries()) {
786
898
  if (poll.reply !== null) {
787
899
  answered.push({ pending: live[index], reply: poll.reply, replies: poll.replies });
788
900
  }
789
- if (poll.failed || poll.degraded)
790
- troubled = true;
901
+ if (poll.degraded)
902
+ transientTrouble = true;
903
+ if (poll.permanentFailure !== null) {
904
+ permanentFailures.set(live[index].request_id, poll.permanentFailure);
905
+ }
791
906
  }
792
- return { answered, troubled };
907
+ return { answered, transientTrouble, permanentFailures };
793
908
  }
794
909
  /** The registered-question queue. Anything but the current shape reads as empty. */
795
910
  function pendingList(state) {
@@ -797,6 +912,9 @@ function pendingList(state) {
797
912
  }
798
913
  /** Registration identity — the convention every racing writer compares by. */
799
914
  function isSamePending(a, b) {
915
+ if (a.question_id !== undefined || b.question_id !== undefined) {
916
+ return a.question_id !== undefined && a.question_id === b.question_id;
917
+ }
800
918
  return a.question === b.question && a.asked_at === b.asked_at;
801
919
  }
802
920
  /** The question set this pending record pushes, however it was registered. */
@@ -849,25 +967,39 @@ function answersContext(answered, remaining) {
849
967
  });
850
968
  return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`;
851
969
  }
852
- function userPromptAnswerOutput(context) {
970
+ function stopAnswerOutput(context) {
853
971
  return JSON.stringify({
854
- hookSpecificOutput: {
855
- hookEventName: 'UserPromptSubmit',
856
- additionalContext: context,
857
- },
972
+ decision: 'block',
973
+ reason: context,
858
974
  });
859
975
  }
860
- function stopAnswerOutput(context) {
861
- return JSON.stringify({ decision: 'block', reason: context });
862
- }
863
976
  /**
864
977
  * Close, truthfully retire, and forget every answered pending question in one
865
978
  * state write, without dropping any other retirement debt or any question
866
979
  * still waiting. Stop answers also open one bounded continuation generation;
867
980
  * UserPromptSubmit answers ride the user's new turn.
868
981
  */
869
- async function finishAnsweredPendings(ctx, envelope, sessionId, answered, continuation) {
870
- const retirements = answered
982
+ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
983
+ updateSessionState(sessionId, ctx.env, (current) => {
984
+ const pendingRemaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
985
+ const next = {
986
+ ...current,
987
+ accepted: {
988
+ answers: answered,
989
+ remaining,
990
+ recorded_at: ctx.now(),
991
+ },
992
+ };
993
+ if (pendingRemaining.length > 0)
994
+ next.pending = pendingRemaining;
995
+ else
996
+ delete next.pending;
997
+ return next;
998
+ });
999
+ }
1000
+ /** Settle only after a successor hook proves the harness received the answer. */
1001
+ function settleAcceptedAnswers(ctx, sessionId, accepted) {
1002
+ const retirements = accepted.answers
871
1003
  .map(({ pending }) => retiringQuestion(pending, 'answered'))
872
1004
  .filter((entry) => entry !== null);
873
1005
  updateSessionState(sessionId, ctx.env, (current) => {
@@ -879,24 +1011,39 @@ async function finishAnsweredPendings(ctx, envelope, sessionId, answered, contin
879
1011
  else
880
1012
  retiring[existing] = retirement;
881
1013
  }
882
- const remaining = pendingList(current).filter((entry) => !answered.some(({ pending }) => isSamePending(entry, pending)));
883
1014
  const next = { ...current, retiring };
884
- if (remaining.length > 0)
885
- next.pending = remaining;
1015
+ delete next.accepted;
1016
+ next.continuation = {
1017
+ answered_at: accepted.recorded_at,
1018
+ count: accepted.continuation_count ?? 1,
1019
+ };
1020
+ return next;
1021
+ });
1022
+ }
1023
+ async function retirePendings(ctx, envelope, sessionId, entries, state) {
1024
+ updateSessionState(sessionId, ctx.env, (current) => {
1025
+ const retiring = [...(current.retiring ?? [])];
1026
+ for (const entry of entries) {
1027
+ const retirement = retiringQuestion(entry, state);
1028
+ if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1029
+ retiring.push(retirement);
1030
+ }
1031
+ }
1032
+ const pending = pendingList(current).filter((candidate) => !entries.some((entry) => isSamePending(candidate, entry)));
1033
+ const next = { ...current };
1034
+ if (pending.length > 0)
1035
+ next.pending = pending;
886
1036
  else
887
1037
  delete next.pending;
888
- if (continuation) {
889
- next.continuation = {
890
- answered_at: ctx.now(),
891
- count: (envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
892
- };
893
- }
894
- else {
895
- next.last_prompt_at = ctx.now();
896
- }
1038
+ if (retiring.length > 0)
1039
+ next.retiring = retiring;
1040
+ else
1041
+ delete next.retiring;
897
1042
  return next;
898
1043
  });
899
- await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
1044
+ // Network retirement is deliberately deferred to a later no-question hook.
1045
+ // The state write above is the durable operation; blocking here creates a
1046
+ // crash window before an answer can reach harness stdout.
900
1047
  }
901
1048
  // ---------------------------------------------------------------------------
902
1049
  // UserPromptSubmit — the user is at the keyboard
@@ -913,34 +1060,63 @@ export async function handleUserPromptSubmit(ctx, envelope) {
913
1060
  if (!sessionId)
914
1061
  return { notes };
915
1062
  const state = readSessionState(sessionId, ctx.env);
1063
+ if (state.accepted !== undefined) {
1064
+ updateSessionState(sessionId, ctx.env, (current) => ({
1065
+ ...current,
1066
+ last_prompt_at: ctx.now(),
1067
+ }));
1068
+ if (envelope.cwd !== undefined && ctx.harness !== undefined) {
1069
+ writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
1070
+ }
1071
+ notes.push('a device answer is safely journaled; the next Stop will deliver it');
1072
+ return { notes };
1073
+ }
916
1074
  const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
917
1075
  let lateAnswers = [];
918
1076
  if (live.length > 0) {
919
- const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
920
- lateAnswers = answered;
921
- if (answered.length > 0) {
922
- await finishAnsweredPendings(ctx, envelope, sessionId, answered, false);
923
- for (const { reply } of answered) {
924
- notes.push(`late answer from ${reply.device_name}: ${reply.text}`);
1077
+ const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
1078
+ const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
1079
+ lateAnswers = answered.map((entry) => {
1080
+ const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
1081
+ const replies = response?.replies.length ? response.replies : entry.replies;
1082
+ if (response === null) {
1083
+ notes.push(`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`);
925
1084
  }
1085
+ return { ...entry, replies, reply: replies.at(-1) };
1086
+ });
1087
+ if (answered.length > 0) {
1088
+ stageAcceptedAnswers(ctx, sessionId, lateAnswers, 0);
1089
+ for (const answer of lateAnswers)
1090
+ reportAnswer(ctx, notes, answer, true);
926
1091
  }
927
- if (troubled) {
1092
+ if (transientTrouble) {
928
1093
  notes.push('could not check every pending question for a late answer before the prompt');
929
1094
  }
1095
+ const permanentFailure = permanentReplyFailureNote(permanentFailures);
1096
+ if (permanentFailure !== null)
1097
+ notes.push(permanentFailure);
930
1098
  }
931
1099
  // Park before dropping `pending`. If the process dies between these writes,
932
1100
  // the next hook sees both copies and dedupes them; the old order could die in
933
1101
  // the gap after erasing the only request/collapse/device identifiers.
934
1102
  updateSessionState(sessionId, ctx.env, (current) => {
935
1103
  const retiring = [...(current.retiring ?? [])];
936
- for (const entry of pendingList(current)) {
1104
+ const unasked = pendingList(current).filter((entry) => entry.request_id === undefined);
1105
+ for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
937
1106
  const retirement = retiringQuestion(entry, 'answered_elsewhere');
938
1107
  if (retirement !== null &&
939
1108
  !retiring.some((parked) => parked.request_id === retirement.request_id)) {
940
1109
  retiring.push(retirement);
941
1110
  }
942
1111
  }
943
- return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) };
1112
+ return {
1113
+ last_prompt_at: ctx.now(),
1114
+ ...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
1115
+ ...(unasked.length > 0 ? { pending: unasked } : {}),
1116
+ ...(retiring.length > 0 ? { retiring } : {}),
1117
+ ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1118
+ ...(current.continuation === undefined ? {} : { continuation: current.continuation }),
1119
+ };
944
1120
  });
945
1121
  // The bridge that lets a plain `notifai ask` find the hook's canonical
946
1122
  // session: an agent shell command gets no hook payload, and not every
@@ -948,78 +1124,162 @@ export async function handleUserPromptSubmit(ctx, envelope) {
948
1124
  if (envelope.cwd !== undefined && ctx.harness !== undefined) {
949
1125
  writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
950
1126
  }
1127
+ if (lateAnswers.length > 0) {
1128
+ // UserPromptSubmit has no later hook field proving its stdout reached the
1129
+ // model. Keep the journal and let the next Stop use its acknowledged
1130
+ // continuation channel instead of recreating the crash-before-stdout gap.
1131
+ notes.push('the late device answer will continue the agent at this turn’s Stop');
1132
+ return { notes };
1133
+ }
951
1134
  const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope));
952
1135
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
953
1136
  const swept = [...retired, ...orphaned];
954
1137
  if (swept.length > 0) {
955
1138
  notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
956
1139
  }
957
- if (lateAnswers.length > 0) {
958
- // Whatever the user did not answer was just retired above — they are at
959
- // the keyboard now, so nothing is "still waiting" from their side.
960
- return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes };
961
- }
962
1140
  return { notes };
963
1141
  }
964
1142
  // ---------------------------------------------------------------------------
965
1143
  // Stop — the turn ended; escalate a registered question
966
1144
  // ---------------------------------------------------------------------------
967
- /**
968
- * How long an escalated question keeps accepting an answer after the turn
969
- * ended. Long enough to survive a walk away from the desk, short enough that a
970
- * forgotten question does not resurface days later as a live prompt.
971
- */
972
- const QUESTION_WINDOW_SECONDS = 3600;
973
1145
  /**
974
1146
  * Only engages for a question the agent explicitly registered with
975
1147
  * `notifai ask`. Guessing from the last assistant message was the alternative
976
1148
  * and it is not worth it: a false positive here hijacks the terminal.
977
1149
  */
978
- export async function handleStop(ctx, envelope) {
1150
+ export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + STOP_BUDGET_SECONDS * 1000) {
979
1151
  const notes = [];
1152
+ const hardDeadlineAt = processDeadlineAt;
980
1153
  const sessionId = envelope.session_id;
981
- if (!sessionId)
1154
+ if (!sessionId) {
1155
+ gate(ctx, 'held', 'no-session');
982
1156
  return { notes };
983
- // Anything retired since the last hook is still live on the devices. This
984
- // runs before every early return below, including the nagging guard: a queued
985
- // retirement has nothing to do with whether *this* turn has a question to
986
- // escalate, and the turn that supersedes a question is very often the one
987
- // continuing from the previous answer.
988
- const swept = [
989
- ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
990
- ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
991
- ];
992
- if (swept.length > 0) {
993
- notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
994
1157
  }
995
- const state = readSessionState(sessionId, ctx.env);
996
- const pending = pendingList(state);
997
- if (pending.length === 0)
1158
+ // This event marker is diagnostic evidence in its own right. Record it
1159
+ // before every early return so doctor can distinguish a working Stop route
1160
+ // from the prompt-only state that previously looked healthy.
1161
+ updateSessionState(sessionId, ctx.env, (current) => ({
1162
+ ...current,
1163
+ last_stop_at: ctx.now(),
1164
+ }));
1165
+ // Claim before any reply poll, not only before the grace window. Two racing
1166
+ // Stops can both observe the same accepted journal or live request. An
1167
+ // atomic state update cannot retract duplicate block output a loser already
1168
+ // built, so one session claim owns the complete Stop decision: accepted
1169
+ // replay/ack, late-answer collection, cleanup, and any new escalation.
1170
+ //
1171
+ // Real clock, deliberately, not `ctx.now` — the claim answers "is another
1172
+ // process alive right now", which the injectable clock cannot speak to. It
1173
+ // is also the only clock the *other* process shares.
1174
+ if (!claimQuestionPush(sessionId, ctx.env)) {
1175
+ gate(ctx, 'held', 'claimed-elsewhere');
1176
+ notes.push('another hook is already handling this question');
998
1177
  return { notes };
1178
+ }
1179
+ try {
1180
+ let state = readSessionState(sessionId, ctx.env);
1181
+ if (state.accepted !== undefined) {
1182
+ const accepted = state.accepted;
1183
+ // A recursive Stop is emitted only after the previous Stop's blocking
1184
+ // output was admitted and the continued model turn ran. That event is
1185
+ // the acknowledgment: persisting a separate "offered" bit before stdout
1186
+ // would recreate the crash gap this journal exists to close.
1187
+ const deliveryProven = envelope.stop_hook_active === true;
1188
+ if (!deliveryProven) {
1189
+ updateSessionState(sessionId, ctx.env, (current) => {
1190
+ if (current.accepted === undefined)
1191
+ return current;
1192
+ return {
1193
+ ...current,
1194
+ accepted: {
1195
+ ...current.accepted,
1196
+ continuation_count: (current.continuation?.count ?? 0) + 1,
1197
+ },
1198
+ };
1199
+ });
1200
+ for (const answer of accepted.answers)
1201
+ reportAnswer(ctx, notes, answer, true);
1202
+ return {
1203
+ stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
1204
+ notes,
1205
+ };
1206
+ }
1207
+ settleAcceptedAnswers(ctx, sessionId, accepted);
1208
+ state = readSessionState(sessionId, ctx.env);
1209
+ }
1210
+ const pending = pendingList(state);
1211
+ if (pending.length === 0) {
1212
+ const swept = [
1213
+ ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
1214
+ ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
1215
+ ];
1216
+ if (swept.length > 0) {
1217
+ notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
1218
+ }
1219
+ gate(ctx, 'held', 'no-question');
1220
+ return { notes };
1221
+ }
1222
+ return await handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt);
1223
+ }
1224
+ finally {
1225
+ releaseQuestionPush(sessionId, ctx.env);
1226
+ }
1227
+ }
1228
+ /** The complete per-session Stop decision, after this process owns the claim. */
1229
+ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes, hardDeadlineAt) {
999
1230
  const live = pending.filter((entry) => entry.request_id !== undefined);
1000
1231
  const unasked = pending.filter((entry) => entry.request_id === undefined);
1232
+ let liveToEscalate = live;
1233
+ if (ctx.harness !== undefined &&
1234
+ HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported') {
1235
+ for (const entry of live)
1236
+ await closeQuietly(ctx, entry.request_id);
1237
+ await retirePendings(ctx, envelope, sessionId, pending, 'expired');
1238
+ gate(ctx, 'held', 'no-question');
1239
+ notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
1240
+ return { notes };
1241
+ }
1001
1242
  if (live.length > 0) {
1002
- const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS);
1243
+ const { answered, permanentFailures } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS);
1244
+ const permanentFailure = permanentReplyFailureNote(permanentFailures);
1245
+ if (permanentFailure !== null)
1246
+ notes.push(permanentFailure);
1247
+ const permanentlyRejected = live.filter((entry) => permanentFailures.has(entry.request_id));
1248
+ await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id)));
1249
+ if (permanentlyRejected.length > 0) {
1250
+ await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
1251
+ }
1003
1252
  if (answered.length > 0) {
1004
- await finishAnsweredPendings(ctx, envelope, sessionId, answered, true);
1005
- for (const { reply } of answered) {
1006
- notes.push(`late answer from ${reply.device_name}: ${reply.text}`);
1007
- }
1253
+ const finalized = await finalizePendings(ctx, answered.map((entry) => entry.pending));
1254
+ const authoritative = answered.map((entry) => {
1255
+ const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
1256
+ const replies = response?.replies.length ? response.replies : entry.replies;
1257
+ if (response === null) {
1258
+ notes.push(`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`);
1259
+ }
1260
+ return { ...entry, replies, reply: replies.at(-1) };
1261
+ });
1262
+ stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
1263
+ gate(ctx, 'proceeding', 'answered', { answers: authoritative.length });
1264
+ for (const answer of authoritative)
1265
+ reportAnswer(ctx, notes, answer, true);
1008
1266
  // Anything still unasked rides the next Stop: the agent is being resumed
1009
1267
  // with answers right now, and may not even need the rest afterwards.
1010
1268
  return {
1011
- stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
1269
+ stdout: stopAnswerOutput(answersContext(authoritative, pending.length - authoritative.length - permanentlyRejected.length)),
1012
1270
  notes,
1013
1271
  };
1014
1272
  }
1273
+ const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1274
+ liveToEscalate = recoverableLive;
1015
1275
  if (unasked.length === 0) {
1016
- // Already live on the user's devices from an earlier Stop; asking twice
1017
- // for one question is the nagging failure this feature exists to avoid.
1018
- const ids = live.map((entry) => entry.request_id).join(', ');
1019
- notes.push(troubled
1020
- ? `already asked (${ids}); could not check whether ${live.length === 1 ? 'its answer' : 'their answers'} arrived`
1021
- : `already asked (${ids}); waiting for ${live.length === 1 ? 'that answer' : 'those answers'}`);
1022
- return { notes };
1276
+ if (recoverableLive.length === 0)
1277
+ return { notes };
1278
+ // A previous answer may have resumed the model while independent
1279
+ // questions remain. The next Stop is their successor owner: keep waiting
1280
+ // to the original absolute deadline instead of polling for four seconds
1281
+ // and abandoning them.
1282
+ return await escalate(ctx, envelope, sessionId, [], recoverableLive, notes, hardDeadlineAt);
1023
1283
  }
1024
1284
  // Questions registered after the earlier push still owe the user their
1025
1285
  // notification; fall through and escalate just those.
@@ -1032,147 +1292,339 @@ export async function handleStop(ctx, envelope) {
1032
1292
  const isNew = continuation !== undefined &&
1033
1293
  unasked.some((entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at);
1034
1294
  if (!isNew) {
1295
+ gate(ctx, 'held', 'continuation-repeat');
1035
1296
  notes.push('already continuing from an answer; not asking again this turn');
1036
1297
  return { notes };
1037
1298
  }
1038
1299
  if (continuation.count >= MAX_CONTINUATION_COUNT) {
1300
+ gate(ctx, 'held', 'continuation-limit', {
1301
+ count: continuation.count,
1302
+ limit: MAX_CONTINUATION_COUNT,
1303
+ });
1039
1304
  notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
1040
1305
  return { notes };
1041
1306
  }
1042
1307
  }
1043
- if (!ctx.config.ask_notifications.value)
1044
- return { notes };
1045
- if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
1046
- notes.push('you are at the keyboard; leaving the question in the terminal');
1308
+ // Silent to the user by design — they switched routing off, so saying so on
1309
+ // every turn would be nagging about their own setting. That silence is also
1310
+ // why it belongs in the log: from outside, "ask_notifications = false" and "a
1311
+ // bug ate my question" look exactly the same.
1312
+ if (!ctx.config.ask_notifications.value) {
1313
+ gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
1047
1314
  return { notes };
1048
1315
  }
1049
- // Claim before the grace window, not after: two racing hooks would otherwise
1050
- // both wait, both find the user still absent, and both push.
1051
- // Real clock, deliberately, not `ctx.now` — the claim answers "is another
1052
- // process alive right now", which the injectable clock cannot speak to. It
1053
- // is also the only clock the *other* process shares.
1054
- if (!claimQuestionPush(sessionId, ctx.env)) {
1055
- notes.push('another hook is already handling this question');
1316
+ const idle = ctx.idleSeconds();
1317
+ if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
1318
+ gate(ctx, 'held', 'user-present', {
1319
+ idle_seconds: idle,
1320
+ away_after_seconds: ctx.config.away_after_seconds.value,
1321
+ require_idle: ctx.config.require_idle.value,
1322
+ last_prompt_at: state.last_prompt_at ?? null,
1323
+ });
1324
+ notes.push('you are at the keyboard; leaving the question in the terminal');
1056
1325
  return { notes };
1057
1326
  }
1058
- try {
1059
- return await escalate(ctx, envelope, sessionId, unasked, live, notes);
1060
- }
1061
- finally {
1062
- releaseQuestionPush(sessionId, ctx.env);
1063
- }
1327
+ gate(ctx, 'proceeding', 'proceeding', {
1328
+ unasked: unasked.length,
1329
+ already_live: liveToEscalate.length,
1330
+ idle_seconds: idle,
1331
+ grace_seconds: ctx.config.ask_grace_seconds.value,
1332
+ });
1333
+ return await escalate(ctx, envelope, sessionId, unasked, liveToEscalate, notes, hardDeadlineAt);
1064
1334
  }
1065
1335
  /** The escalation itself, split out so the claim is released on every path. */
1066
- async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes) {
1336
+ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, hardDeadlineAt) {
1067
1337
  // Away right now, but the questions still owe the user their terminal-first
1068
1338
  // window before anything reaches their devices — measured from the oldest
1069
1339
  // registration, because that is the question that has waited longest.
1070
- const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
1071
- const grace = await awaitGrace(ctx, oldest);
1072
- if (grace === 'user-returned') {
1073
- notes.push('you came back before the wait elapsed; leaving the questions in the terminal');
1074
- return { notes };
1075
- }
1076
- if (grace === 'no-signal') {
1077
- notes.push('no idle signal on this machine; asking now rather than holding the terminal');
1340
+ const budgetStartedAt = ctx.now();
1341
+ const existingDeadlines = alreadyLive.flatMap((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= budgetStartedAt
1342
+ ? []
1343
+ : [entry.reply_deadline_at]);
1344
+ const budgetDeadlineAt = Math.min(hardDeadlineAt - HOOK_TIMING.finalizationReserveSeconds * 1000, ...(existingDeadlines.length === 0 ? [] : existingDeadlines));
1345
+ if (unasked.length > 0 && alreadyLive.length === 0) {
1346
+ const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()));
1347
+ const grace = await awaitGrace(ctx, oldest);
1348
+ ctx.log?.debug('hook.gate', { verdict: 'grace', reason: grace, waited_from: oldest });
1349
+ if (grace === 'user-returned') {
1350
+ gate(ctx, 'held', 'user-returned', { grace_seconds: ctx.config.ask_grace_seconds.value });
1351
+ notes.push('you came back before the wait elapsed; leaving the questions in the terminal');
1352
+ return { notes };
1353
+ }
1354
+ if (grace === 'no-signal') {
1355
+ notes.push('no idle signal on this machine; asking now rather than holding the terminal');
1356
+ }
1078
1357
  }
1079
1358
  // Phase one: every registered question reaches the user's devices, each as
1080
1359
  // its own notification — one ask never stands in for another.
1081
1360
  const submitted = [];
1082
1361
  for (const entry of unasked) {
1362
+ const remainingSeconds = Math.floor((budgetDeadlineAt - ctx.now()) / 1000);
1363
+ if (remainingSeconds <
1364
+ MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds) {
1365
+ notes.push('the Stop budget cannot fit device resolution, submission, and a full reply window; leaving this question in the terminal');
1366
+ continue;
1367
+ }
1368
+ const replyWindowSeconds = Math.min(ctx.config.hook_reply_timeout_seconds.value, remainingSeconds - HOOK_TIMING.submissionReserveSeconds);
1369
+ const ownerDeadlineAt = Math.min(budgetDeadlineAt, ctx.now() +
1370
+ (replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000);
1083
1371
  const questions = pendingQuestions(entry);
1084
- const sent = await submitQuestion(ctx, {
1085
- title: ctx.config.project.value === null
1086
- ? 'Question'
1087
- : `Question · ${ctx.config.project.value}`,
1088
- // A set is answered on the expanded card; the banner leads with the
1089
- // first question and says how much more is waiting behind it.
1090
- body: questions.length > 1
1091
- ? `${questions[0].text} (+${questions.length - 1} more)`
1092
- : entry.question,
1093
- questions,
1094
- ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
1095
- event: 'agent_question',
1096
- session: sessionLabel(ctx, envelope),
1097
- // Outlives the block, and stays open on purpose: the answer is still
1098
- // useful to the next turn, which collects it with `notifai replies`.
1099
- windowSeconds: QUESTION_WINDOW_SECONDS,
1100
- });
1101
- if ('error' in sent) {
1102
- notes.push(sent.error);
1372
+ let intent = entry.submission;
1373
+ if (intent === undefined) {
1374
+ const prepared = await prepareQuestionSubmission(ctx, {
1375
+ title: ctx.config.project.value === null
1376
+ ? 'Question'
1377
+ : `Question · ${ctx.config.project.value}`,
1378
+ // A set is answered on the expanded card; the banner leads with the
1379
+ // first question and says how much more is waiting behind it.
1380
+ body: questions.length > 1
1381
+ ? `${questions[0].text} (+${questions.length - 1} more)`
1382
+ : entry.question,
1383
+ questions,
1384
+ ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
1385
+ event: 'agent_question',
1386
+ session: sessionLabel(ctx, envelope),
1387
+ windowSeconds: replyWindowSeconds,
1388
+ ownerDeadlineAt,
1389
+ });
1390
+ if ('error' in prepared) {
1391
+ ctx.log?.error('hook.pushed', { ok: false, message: prepared.error });
1392
+ notes.push(prepared.error);
1393
+ continue;
1394
+ }
1395
+ intent = prepared;
1396
+ // Durable before submit. If the server commits and the response is lost,
1397
+ // the reserved request id still lets this owner poll and finalize the
1398
+ // exact card; a successor can also replay the frozen draft/key.
1399
+ updateSessionState(sessionId, ctx.env, (current) => {
1400
+ const list = pendingList(current);
1401
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1402
+ if (index < 0)
1403
+ return current;
1404
+ const next = [...list];
1405
+ next[index] = { ...next[index], submission: prepared };
1406
+ return { ...current, pending: next };
1407
+ });
1408
+ }
1409
+ else if (intent.owner_deadline_at <= ctx.now()) {
1410
+ // The frozen wire identity survives a crashed owner, but its local owner
1411
+ // lease does not. Re-arm only the local deadline; request id, key,
1412
+ // targets, and draft remain byte-identical for idempotent replay.
1413
+ const frozenReplyWindow = intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds;
1414
+ intent = {
1415
+ ...intent,
1416
+ owner_deadline_at: Math.min(budgetDeadlineAt, ctx.now() +
1417
+ (frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000),
1418
+ };
1419
+ const rearmed = intent;
1420
+ updateSessionState(sessionId, ctx.env, (current) => {
1421
+ const list = pendingList(current);
1422
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1423
+ if (index < 0)
1424
+ return current;
1425
+ const next = [...list];
1426
+ next[index] = { ...next[index], submission: rearmed };
1427
+ return { ...current, pending: next };
1428
+ });
1429
+ }
1430
+ if (ctx.now() >= intent.owner_deadline_at) {
1431
+ notes.push('the Stop budget ended before submission; preserving the frozen intent');
1103
1432
  continue;
1104
1433
  }
1434
+ const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000);
1435
+ if (beforeSubmitSeconds <
1436
+ MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2) {
1437
+ notes.push('device resolution consumed the submission reserve; preserving the frozen intent instead of shrinking its reply window');
1438
+ continue;
1439
+ }
1440
+ let receipt;
1441
+ let admissionConfirmed = false;
1442
+ try {
1443
+ receipt = await submitQuestion(ctx, intent);
1444
+ admissionConfirmed = true;
1445
+ }
1446
+ catch (err) {
1447
+ if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1448
+ // A concrete client response is an admission rejection, not an
1449
+ // ambiguous commit. Keep the frozen intent for a later retry and leave
1450
+ // the question in the terminal; polling a request the server says it
1451
+ // did not accept only burns the owner window and hides the real fault.
1452
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
1453
+ ctx.log?.error('hook.pushed', {
1454
+ ok: false,
1455
+ request_id: intent.request_id,
1456
+ status: err.status,
1457
+ code: err.code,
1458
+ message: err.message,
1459
+ });
1460
+ continue;
1461
+ }
1462
+ // This is deliberately not a fail-open return. A transport failure is
1463
+ // ambiguous: the server may already have dispatched the card. The
1464
+ // client-generated request id keeps polling/finalization possible.
1465
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1466
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1467
+ }
1468
+ if (admissionConfirmed) {
1469
+ ctx.log?.info('hook.pushed', {
1470
+ ok: true,
1471
+ request_id: intent.request_id,
1472
+ devices: intent.device_ids.length,
1473
+ questions: questions.length,
1474
+ text: questions[0].text,
1475
+ });
1476
+ }
1477
+ const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
1478
+ ? intent.owner_deadline_at
1479
+ : Date.parse(receipt.reply_expires_at);
1105
1480
  const live = {
1106
1481
  ...entry,
1107
- request_id: sent.requestId,
1108
- collapse_key: sent.collapseKey,
1109
- device_ids: sent.devices,
1482
+ request_id: intent.request_id,
1483
+ collapse_key: intent.collapse_key,
1484
+ device_ids: intent.device_ids,
1485
+ // Submission and every later poll share one wall-clock owner. The
1486
+ // server may start its relative window a little later while the submit
1487
+ // response is in flight; closing at this deadline is therefore the
1488
+ // authoritative backstop against accepting an answer into a void.
1489
+ reply_deadline_at: Math.min(intent.owner_deadline_at, Number.isFinite(committedReplyDeadline)
1490
+ ? committedReplyDeadline
1491
+ : intent.owner_deadline_at),
1110
1492
  };
1493
+ delete live.submission;
1111
1494
  submitted.push(live);
1112
1495
  // Record what is now live on the user's devices BEFORE any wait. If we
1113
1496
  // only learned these ids afterwards, a question that timed out would
1114
1497
  // leave no trace, and the user returning to the terminal could never
1115
1498
  // retire it — the notification would stay answerable for an hour with
1116
1499
  // nobody listening.
1117
- updateSessionState(sessionId, ctx.env, (current) => {
1118
- const list = pendingList(current);
1119
- const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
1120
- if (index >= 0) {
1121
- const next = [...list];
1122
- next[index] = live;
1123
- return { ...current, pending: next };
1124
- }
1125
- // The entry vanished while the submit was in flight (the user's prompt
1126
- // wiped the queue). The delivered notification must still be retirable,
1127
- // so park it rather than lose its only identifiers.
1128
- const retirement = retiringQuestion(live, 'answered_elsewhere');
1129
- const retiring = [...(current.retiring ?? [])];
1130
- if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1131
- retiring.push(retirement);
1132
- }
1133
- return { ...current, retiring };
1134
- });
1500
+ if (admissionConfirmed) {
1501
+ updateSessionState(sessionId, ctx.env, (current) => {
1502
+ const list = pendingList(current);
1503
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined);
1504
+ if (index >= 0) {
1505
+ const next = [...list];
1506
+ next[index] = live;
1507
+ return { ...current, pending: next };
1508
+ }
1509
+ // The entry vanished while the submit was in flight (the user's prompt
1510
+ // wiped the queue). The delivered notification must still be retirable,
1511
+ // so park it rather than lose its only identifiers.
1512
+ const retirement = retiringQuestion(live, 'answered_elsewhere');
1513
+ const retiring = [...(current.retiring ?? [])];
1514
+ if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1515
+ retiring.push(retirement);
1516
+ }
1517
+ return { ...current, retiring };
1518
+ });
1519
+ }
1520
+ }
1521
+ const staleLive = alreadyLive.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now());
1522
+ const finalizedStale = await finalizePendings(ctx, staleLive);
1523
+ const staleAnswers = finalizedStale
1524
+ .map(finalizedAnswer)
1525
+ .filter((entry) => entry !== null);
1526
+ const staleSilence = finalizedStale
1527
+ .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
1528
+ .map((entry) => entry.pending);
1529
+ const staleUnproven = finalizedStale.filter((entry) => entry.response === null);
1530
+ if (staleSilence.length > 0) {
1531
+ await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired');
1532
+ notes.push(`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`);
1533
+ }
1534
+ if (staleUnproven.length > 0) {
1535
+ notes.push(`could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`);
1536
+ }
1537
+ if (staleAnswers.length > 0) {
1538
+ const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
1539
+ stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
1540
+ for (const answer of staleAnswers)
1541
+ reportAnswer(ctx, notes, answer, true);
1542
+ return {
1543
+ stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
1544
+ notes,
1545
+ };
1135
1546
  }
1136
- const waitingOn = [...alreadyLive, ...submitted];
1547
+ const waitingOn = [
1548
+ ...alreadyLive.filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now()),
1549
+ ...submitted,
1550
+ ];
1137
1551
  if (waitingOn.length === 0)
1138
1552
  return { notes };
1139
- if (ctx.harness === 'opencode') {
1140
- notes.push(`question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`);
1141
- return { notes };
1142
- }
1143
1553
  // Phase two: one blocking wait across everything live, old and new alike.
1144
- const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value;
1145
- const waited = await waitForAnyReplyWhileAway(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds);
1554
+ const ownerDeadline = Math.min(budgetDeadlineAt, Math.max(...waitingOn.map((entry) => entry.reply_deadline_at)));
1555
+ const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000));
1556
+ const waited = await waitForAnyReplyWhileAway(ctx, waitingOn.map((entry) => entry.request_id), timeoutSeconds, ctx.harness !== 'opencode');
1557
+ const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
1558
+ if (permanentFailure !== null)
1559
+ notes.push(permanentFailure);
1146
1560
  if (waited.byRequest.size === 0) {
1147
- // Keep the pending records so a returning user's UserPromptSubmit can
1148
- // retire the notifications still live on their devices. `request_id`
1149
- // being set is also what stops the next Stop pushing them again.
1150
- const ids = waitingOn.map((entry) => entry.request_id).join(', ');
1561
+ // Once this owner returns, no shipped command-hook harness can guarantee
1562
+ // a later answer reaches the same agent turn. Close first, then retire the
1563
+ // local records, so the Companion never accepts an answer into a void.
1564
+ const finalized = await finalizePendings(ctx, waitingOn);
1565
+ const finalAnswers = finalized
1566
+ .map(finalizedAnswer)
1567
+ .filter((entry) => entry !== null);
1568
+ const confirmedSilent = finalized
1569
+ .filter((entry) => entry.response !== null && entry.response.replies.length === 0)
1570
+ .map((entry) => entry.pending);
1571
+ const unproven = finalized.filter((entry) => entry.response === null);
1572
+ if (confirmedSilent.length > 0) {
1573
+ await retirePendings(ctx, envelope, sessionId, confirmedSilent, waited.userReturned ? 'answered_elsewhere' : 'expired');
1574
+ }
1575
+ if (unproven.length > 0) {
1576
+ notes.push(`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`);
1577
+ }
1578
+ if (finalAnswers.length > 0) {
1579
+ const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length);
1580
+ stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
1581
+ for (const answer of finalAnswers)
1582
+ reportAnswer(ctx, notes, answer, false);
1583
+ return {
1584
+ stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
1585
+ notes,
1586
+ };
1587
+ }
1588
+ const requestIdSummary = summarizeRequestIds(waitingOn);
1589
+ ctx.log?.info('hook.answer', {
1590
+ answered: false,
1591
+ request_ids: requestIdSummary.ids,
1592
+ user_returned: waited.userReturned,
1593
+ degraded: waited.degraded,
1594
+ permanent_failures: waited.permanentFailures.size,
1595
+ waited_seconds: timeoutSeconds,
1596
+ });
1151
1597
  if (waited.userReturned) {
1152
- notes.push(`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; returning the terminal while ${waitingOn.length === 1 ? 'it stays' : 'they stay'} answerable (${ids}). ` +
1153
- 'Retrieve answers with: notifai replies --pending');
1598
+ notes.push(`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; ${waitingOn.length === 1 ? 'it was' : 'they were'} retired before returning the terminal (${requestIdSummary.display})`);
1154
1599
  }
1155
- else {
1600
+ else if (waited.permanentFailures.size === 0) {
1156
1601
  notes.push(waited.degraded
1157
- ? 'could not reach the server to find out whether you answered; check with: notifai replies --pending'
1158
- : 'no answer in time; retrieve it later with: notifai replies --pending');
1602
+ ? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
1603
+ : 'no answer in time; the question expired with its continuation owner');
1159
1604
  }
1160
1605
  return { notes };
1161
1606
  }
1607
+ const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id));
1608
+ const finalizedAnswered = await finalizePendings(ctx, polledAnswered);
1162
1609
  const answered = [];
1163
- for (const entry of waitingOn) {
1610
+ for (const finalized of finalizedAnswered) {
1611
+ const entry = finalized.pending;
1164
1612
  const replies = waited.byRequest.get(entry.request_id);
1165
1613
  if (replies === undefined)
1166
1614
  continue;
1167
- // First answer claims the question across devices; the close is what
1168
- // enforces that, and the latest reply within the window is the answer.
1169
- await closeQuietly(ctx, entry.request_id);
1170
- answered.push({ pending: entry, reply: replies.at(-1), replies });
1171
- }
1172
- await finishAnsweredPendings(ctx, envelope, sessionId, answered, true);
1173
- for (const { reply } of answered) {
1174
- notes.push(`answer from ${reply.device_name}: ${reply.text}`);
1615
+ // The fenced set wins because it includes corrections that committed
1616
+ // while close was waiting for earlier writers. If finalization itself was
1617
+ // unreachable, never discard the durable reply already observed.
1618
+ const finalReplies = finalized.response?.replies ?? [];
1619
+ const authoritative = finalReplies.length > 0 ? finalReplies : replies;
1620
+ answered.push({ pending: entry, reply: authoritative.at(-1), replies: authoritative });
1621
+ if (finalized.response === null) {
1622
+ notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
1623
+ }
1175
1624
  }
1625
+ stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
1626
+ for (const answer of answered)
1627
+ reportAnswer(ctx, notes, answer, false);
1176
1628
  return {
1177
1629
  stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
1178
1630
  notes,
@@ -1193,7 +1645,7 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1193
1645
  const notes = [];
1194
1646
  const sessionId = envelope.session_id;
1195
1647
  if (!sessionId)
1196
- return { notes };
1648
+ return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
1197
1649
  if (envelope.cwd !== undefined)
1198
1650
  clearMatchingProjectSession(envelope.cwd, env, sessionId);
1199
1651
  const state = readSessionState(sessionId, env);
@@ -1208,7 +1660,10 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1208
1660
  notes.push(err instanceof Error ? err.message : String(err));
1209
1661
  // Preserve the only identifiers instead of turning an explicit corrupt
1210
1662
  // state into an unretirable question during SessionEnd cleanup.
1211
- return { notes };
1663
+ return {
1664
+ notes,
1665
+ log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
1666
+ };
1212
1667
  }
1213
1668
  }
1214
1669
  if (orphans.length > 0) {
@@ -1216,8 +1671,27 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1216
1671
  orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now);
1217
1672
  notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
1218
1673
  }
1674
+ if (state.accepted !== undefined) {
1675
+ const preserved = { accepted: state.accepted };
1676
+ if (state.last_prompt_at !== undefined)
1677
+ preserved.last_prompt_at = state.last_prompt_at;
1678
+ if (state.last_stop_at !== undefined)
1679
+ preserved.last_stop_at = state.last_stop_at;
1680
+ if (state.continuation !== undefined)
1681
+ preserved.continuation = state.continuation;
1682
+ writeSessionState(sessionId, env, preserved);
1683
+ notes.push('preserved an accepted device answer for this exact session to resume');
1684
+ return {
1685
+ notes,
1686
+ log: {
1687
+ outcome: 'answer-preserved',
1688
+ queued_retirements: orphans.length,
1689
+ accepted_answers: state.accepted.answers.length,
1690
+ },
1691
+ };
1692
+ }
1219
1693
  clearSessionState(sessionId, env);
1220
- return { notes };
1694
+ return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } };
1221
1695
  }
1222
1696
  // ---------------------------------------------------------------------------
1223
1697
  // ask — register a question for the turn's end
@@ -1254,6 +1728,7 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
1254
1728
  pending: [
1255
1729
  ...pending,
1256
1730
  {
1731
+ question_id: `q_${randomBytes(12).toString('base64url')}`,
1257
1732
  asked_at: now,
1258
1733
  ...question,
1259
1734
  question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),