@raidiant/notifai 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
package/dist/hooks.js CHANGED
@@ -1,21 +1,38 @@
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 { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol';
4
5
  import { ApiCallError, isRetryableReplyPollError, } from './client.js';
5
6
  import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
6
7
  import { buildDraft } from './send.js';
7
8
  import { atomicWriteFileSync } from './atomic-file.js';
8
9
  import { withFileLock } from './file-lock.js';
10
+ import { inferInvocationContext } from './invocation-context.js';
9
11
  import { HARNESS_CAPABILITIES, HARNESSES, } from './harnesses.js';
10
12
  /**
11
- * Past this, the question's reply window (3600s) has long expired server-side
12
- * and the companion shows it as dead on next open anyway; pushing a retirement
13
- * sync for it is noise. Also the backstop that keeps an unreachable server
14
- * from growing the queue for ever.
13
+ * A retirement is only worth syncing while the question it retires could still
14
+ * be answered, so this has to outlive the longest reply window a user can
15
+ * choose — it is derived from that bound rather than from a guess about how
16
+ * long questions stay open, which is exactly the assumption that broke when
17
+ * the window stopped being one hour. Past it the companion shows the question
18
+ * as dead on next open anyway. Also the backstop that keeps an unreachable
19
+ * server from growing the queue for ever.
15
20
  */
16
- const ORPHAN_TTL_MS = 24 * 3600 * 1000;
21
+ const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000;
17
22
  /** More orphans than this means something is looping; keep the newest. */
18
23
  const ORPHAN_QUEUE_CAP = 50;
24
+ /** Keep immutable session grouping while replacing per-event Git location authoritatively. */
25
+ function sourceContextAtHookEvent(source, cwd) {
26
+ const invocation = cwd === undefined ? null : inferInvocationContext(cwd);
27
+ const current = {
28
+ ...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
29
+ ...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
30
+ ...(source?.harness !== undefined ? { harness: source.harness } : {}),
31
+ ...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
32
+ ...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
33
+ };
34
+ return Object.keys(current).length === 0 ? undefined : current;
35
+ }
19
36
  function summarizeRequestIds(entries) {
20
37
  const ids = entries.flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]);
21
38
  return { ids, display: ids.join(', ') };
@@ -30,12 +47,33 @@ function summarizeRequestIds(entries) {
30
47
  * question is only how long that host is willing to wait in total.
31
48
  */
32
49
  export const WAITER_CEILING_SECONDS = 480;
50
+ /**
51
+ * The same wall clock where the waiter runs in the background instead of
52
+ * inside a held turn.
53
+ *
54
+ * On a host that holds its turn, 480 s is a real cost to the person sitting in
55
+ * front of it, and the number above is right. On Claude Code the Stop hook
56
+ * returns at once and the waiter is detached, so the same 480 s buys nothing
57
+ * and costs a direct wake: an answer arriving at minute nine had to wait for
58
+ * the session's next turn even though nothing was blocked.
59
+ *
60
+ * This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
61
+ * because that declaration's kill is silent, and under the reply window the
62
+ * question was pushed with. Past it the journal still delivers at the next
63
+ * turn — the ceiling decides how long a *direct* wake stays possible, never
64
+ * whether the answer survives.
65
+ */
66
+ export const DETACHED_WAITER_CEILING_SECONDS = 3300;
67
+ /** The waiter's wall clock, which depends on whether a turn is held open for it. */
68
+ export function waiterCeilingSeconds(detached) {
69
+ return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS;
70
+ }
33
71
  /**
34
72
  * The wire contract's shortest reply window. A question submitted with less
35
73
  * than this is rejected outright, and accepting one would in any case let the
36
74
  * server go on taking an answer after the waiter that owns it has returned.
37
75
  */
38
- const MIN_REPLY_WINDOW_SECONDS = 60;
76
+ export const MIN_REPLY_WINDOW_SECONDS = 60;
39
77
  /**
40
78
  * The terminal-first wait: the question sits in the terminal for
41
79
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -168,6 +206,16 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
168
206
  * no trustworthy PID fall back to an age limit.
169
207
  */
170
208
  const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
209
+ /**
210
+ * How long a crashed claim *guard* blocks the next hook.
211
+ *
212
+ * The claim itself may legitimately be held for the whole waiter, so it takes
213
+ * that ceiling. The guard is a short lock around the claim's own bookkeeping —
214
+ * a few file operations — and borrowing the waiter's ceiling for it meant a
215
+ * hook killed at exactly the wrong moment wedged this session's Stop path for
216
+ * eight minutes, for a critical section that never runs longer than a second.
217
+ */
218
+ const CLAIM_GUARD_TTL_MS = 30_000;
171
219
  const heldClaims = new Map();
172
220
  export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
173
221
  const file = claimPath(sessionId, env);
@@ -266,7 +314,7 @@ function acquireClaimGuard(file) {
266
314
  if (err.code !== 'EEXIST')
267
315
  return false;
268
316
  try {
269
- if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS)
317
+ if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS)
270
318
  return false;
271
319
  }
272
320
  catch {
@@ -287,52 +335,70 @@ function claimPath(sessionId, env) {
287
335
  * meantime, and keeps one runaway agent from writing megabytes per session.
288
336
  */
289
337
  const MAX_STORED_QUESTION_CHARS = 2000;
338
+ function storedProjectSessionPointer(candidate) {
339
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
340
+ return null;
341
+ const entry = candidate;
342
+ if (typeof entry.session_id !== 'string' || entry.session_id === '')
343
+ return null;
344
+ if (typeof entry.updated_at !== 'number')
345
+ return null;
346
+ if (!HARNESSES.includes(entry.harness))
347
+ return null;
348
+ return {
349
+ sessionId: entry.session_id,
350
+ updatedAt: entry.updated_at,
351
+ harness: entry.harness,
352
+ };
353
+ }
354
+ function readStoredProjectSessionDocument(file) {
355
+ if (!existsSync(file))
356
+ return { root: {}, sessions: [] };
357
+ try {
358
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
359
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
360
+ return { root: {}, sessions: [] };
361
+ }
362
+ const root = parsed;
363
+ return {
364
+ root,
365
+ sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
366
+ };
367
+ }
368
+ catch {
369
+ return { root: {}, sessions: [] };
370
+ }
371
+ }
290
372
  /** Records every live session working in a directory, for `notifai ask`. */
291
373
  export function writeProjectSession(cwd, env, sessionId, now, harness) {
292
374
  const file = projectSessionPointerPath(cwd, env);
293
375
  withFileLock(`${file}.lock`, () => {
294
- const existing = readStoredProjectSessionPointers(file).filter((entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000);
376
+ const stored = readStoredProjectSessionDocument(file);
377
+ const existing = [];
378
+ let refreshed = {};
379
+ for (const candidate of stored.sessions) {
380
+ const entry = storedProjectSessionPointer(candidate);
381
+ if (entry?.sessionId === sessionId) {
382
+ refreshed = { ...refreshed, ...candidate };
383
+ }
384
+ else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
385
+ existing.push(candidate);
386
+ }
387
+ }
295
388
  atomicWriteFileSync(file, `${JSON.stringify({
389
+ ...stored.root,
296
390
  sessions: [
297
- ...existing.map((entry) => ({
298
- session_id: entry.sessionId,
299
- updated_at: entry.updatedAt,
300
- harness: entry.harness,
301
- })),
302
- { session_id: sessionId, updated_at: now, harness },
391
+ ...existing,
392
+ { ...refreshed, session_id: sessionId, updated_at: now, harness },
303
393
  ],
304
394
  })}\n`);
305
395
  });
306
396
  }
307
397
  function readStoredProjectSessionPointers(file) {
308
- if (!existsSync(file))
309
- return [];
310
- try {
311
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
312
- if (!Array.isArray(parsed.sessions))
313
- return [];
314
- return parsed.sessions.flatMap((candidate) => {
315
- if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
316
- return [];
317
- const entry = candidate;
318
- if (typeof entry.session_id !== 'string' || entry.session_id === '')
319
- return [];
320
- if (typeof entry.updated_at !== 'number')
321
- return [];
322
- if (!HARNESSES.includes(entry.harness))
323
- return [];
324
- return [
325
- {
326
- sessionId: entry.session_id,
327
- updatedAt: entry.updated_at,
328
- harness: entry.harness,
329
- },
330
- ];
331
- });
332
- }
333
- catch {
334
- return [];
335
- }
398
+ return readStoredProjectSessionDocument(file).sessions.flatMap((candidate) => {
399
+ const entry = storedProjectSessionPointer(candidate);
400
+ return entry === null ? [] : [entry];
401
+ });
336
402
  }
337
403
  /**
338
404
  * Resolves the session working in `cwd`. Stale pointers are ignored rather than
@@ -386,20 +452,46 @@ export function readMatchingProjectSessionPointer(cwd, env, now, sessionId, harn
386
452
  function clearMatchingProjectSession(cwd, env, sessionId) {
387
453
  const file = projectSessionPointerPath(cwd, env);
388
454
  withFileLock(`${file}.lock`, () => {
389
- const remaining = readStoredProjectSessionPointers(file).filter((entry) => entry.sessionId !== sessionId);
390
- if (remaining.length === 0) {
455
+ const stored = readStoredProjectSessionDocument(file);
456
+ const remaining = stored.sessions.filter((candidate) => {
457
+ const entry = storedProjectSessionPointer(candidate);
458
+ return entry === null || entry.sessionId !== sessionId;
459
+ });
460
+ const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions');
461
+ if (remaining.length === 0 && !hasUnknownRootFields) {
391
462
  rmSync(file, { force: true });
392
463
  return;
393
464
  }
394
465
  atomicWriteFileSync(file, `${JSON.stringify({
395
- sessions: remaining.map((entry) => ({
396
- session_id: entry.sessionId,
397
- updated_at: entry.updatedAt,
398
- harness: entry.harness,
399
- })),
466
+ ...stored.root,
467
+ sessions: remaining,
400
468
  })}\n`);
401
469
  });
402
470
  }
471
+ /**
472
+ * Record one gate decision.
473
+ *
474
+ * `verdict` is what happened to the question and `reason` is the closed-set
475
+ * name of the rule that decided it, so an agent can ask "why was it held" with
476
+ * a filter rather than by matching on prose. The prose still exists — it is what
477
+ * the user sees in the terminal — but it is free to be rewritten, and a log a
478
+ * filter cannot rely on is a log nobody filters.
479
+ */
480
+ export const GATE_REASONS = [
481
+ 'no-session',
482
+ 'no-question',
483
+ 'answered',
484
+ 'continuation-repeat',
485
+ 'continuation-limit',
486
+ 'delivery-limit',
487
+ 'acknowledgement-required',
488
+ 'acknowledgement-abandoned',
489
+ 'harness-cannot-continue',
490
+ 'notifications-off',
491
+ 'claimed-elsewhere',
492
+ 'elapsed',
493
+ 'proceeding',
494
+ ];
403
495
  function gate(ctx, verdict, reason, data = {}) {
404
496
  ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data });
405
497
  }
@@ -440,7 +532,7 @@ async function prepareQuestionSubmission(ctx, options) {
440
532
  ...(options.media !== undefined ? { media: options.media } : {}),
441
533
  device: answerable,
442
534
  reply: true,
443
- replyWindow: Math.max(60, options.windowSeconds),
535
+ replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
444
536
  questions: options.questions,
445
537
  collapseKey,
446
538
  }, options.source === undefined ? {} : { source: options.source });
@@ -541,7 +633,8 @@ async function answerableDevices(ctx) {
541
633
  return devices
542
634
  .filter((device) => (device.platform === 'ios' || device.platform === 'macos') &&
543
635
  device.registration_healthy &&
544
- device.reply_protocol_version === 2)
636
+ device.capabilities?.includes('answer') === true &&
637
+ device.derived_status !== 'must_update')
545
638
  .filter((device) => configured === null || configured.includes(device.device_id))
546
639
  .map((device) => device.device_id);
547
640
  }
@@ -602,7 +695,6 @@ const RETIREMENT_TITLES = {
602
695
  answered: 'Answered',
603
696
  answered_elsewhere: 'Answered in the terminal',
604
697
  expired: 'Question expired',
605
- superseded: 'Replaced by a newer question',
606
698
  };
607
699
  /**
608
700
  * Park a delivered question for retirement. Called at the moment we learn it is
@@ -637,7 +729,7 @@ export function parkForRetirement(sessionId, env, pending, state) {
637
729
  * question or retire a device that never received it. Fail explicitly and keep
638
730
  * the pending record instead of silently broadening or narrowing the fanout.
639
731
  */
640
- function retiringQuestion(pending, state) {
732
+ function retiringQuestion(pending, state, cwd) {
641
733
  const hasRequest = pending.request_id !== undefined;
642
734
  const hasCollapse = pending.collapse_key !== undefined;
643
735
  const hasDevices = pending.device_ids !== undefined && pending.device_ids.length > 0;
@@ -646,13 +738,14 @@ function retiringQuestion(pending, state) {
646
738
  if (!hasRequest || !hasCollapse || !hasDevices) {
647
739
  throw new Error('live question state is incomplete; refusing to retire it without request, collapse, and device identifiers');
648
740
  }
741
+ const source = sourceContextAtHookEvent(pending.source, cwd);
649
742
  return {
650
743
  request_id: pending.request_id,
651
744
  collapse_key: pending.collapse_key,
652
745
  device_ids: [...pending.device_ids],
653
746
  question: pending.question,
654
747
  ...(pending.project !== undefined ? { project: pending.project } : {}),
655
- ...(pending.source !== undefined ? { source: pending.source } : {}),
748
+ ...(source !== undefined ? { source } : {}),
656
749
  state,
657
750
  };
658
751
  }
@@ -764,6 +857,20 @@ const LATE_PROMPT_POLL_SECONDS = 3;
764
857
  * produce.
765
858
  */
766
859
  export const MAX_CONTINUATION_COUNT = 3;
860
+ /**
861
+ * How many turns an answer may be *held* before it is settled unread.
862
+ *
863
+ * A hold is not a delivery: the route reported that it handed nothing over, so
864
+ * the agent has not seen the answer and the wake-loop the continuation cap
865
+ * exists to stop has not happened. Counting holds against that cap meant three
866
+ * turns where a liveness probe could not reach a busy session were enough to
867
+ * discard an answer the user had already given.
868
+ *
869
+ * It still has to end. A route that can never deliver would otherwise retry
870
+ * every turn for the life of the session, so holds get their own, far looser
871
+ * bound — loose because each one costs nothing and the answer is still wanted.
872
+ */
873
+ export const MAX_HELD_DELIVERIES = 20;
767
874
  /** One bounded recovery poll for a question that outlived its original wait. */
768
875
  async function pollPendingReply(ctx, pending, timeoutSeconds) {
769
876
  if (pending.request_id === undefined) {
@@ -802,6 +909,7 @@ function finalizedAnswer(finalized) {
802
909
  reply,
803
910
  replies,
804
911
  agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
912
+ agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
805
913
  };
806
914
  }
807
915
  /** Persist and surface one answer without letting the two representations drift. */
@@ -848,6 +956,38 @@ function isSamePending(a, b) {
848
956
  }
849
957
  return a.question === b.question && a.asked_at === b.asked_at;
850
958
  }
959
+ /**
960
+ * The frozen draft itself will never be accepted. Replaying it forever hides a
961
+ * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
962
+ * Auth, conflicts, and timeouts are not this class.
963
+ */
964
+ function isTerminalDraftRejection(err) {
965
+ return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
966
+ }
967
+ function dropPendingQuestion(sessionId, env, entry) {
968
+ updateSessionState(sessionId, env, (current) => {
969
+ const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
970
+ const next = { ...current };
971
+ if (pending.length > 0)
972
+ next.pending = pending;
973
+ else
974
+ delete next.pending;
975
+ return next;
976
+ });
977
+ }
978
+ function clearFrozenSubmission(sessionId, env, entry) {
979
+ updateSessionState(sessionId, env, (current) => {
980
+ const list = pendingList(current);
981
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
982
+ if (index < 0)
983
+ return current;
984
+ const next = [...list];
985
+ const copy = { ...next[index] };
986
+ delete copy.submission;
987
+ next[index] = copy;
988
+ return { ...current, pending: next };
989
+ });
990
+ }
851
991
  /** The question set this pending record pushes, however it was registered. */
852
992
  function pendingQuestions(pending) {
853
993
  return pending.questions ?? [{ id: 'q1', text: pending.question }];
@@ -890,8 +1030,19 @@ function answerContext(answered) {
890
1030
  * tied to the question that asked it, with a truthful note about anything
891
1031
  * still waiting.
892
1032
  */
893
- function acknowledgementCommand(requestId) {
894
- return `notifai acknowledge ${requestId} --text <text>`;
1033
+ function acknowledgementCommand(requestId, textRequired = true) {
1034
+ return textRequired
1035
+ ? `notifai acknowledge ${requestId} --text <text>`
1036
+ : `notifai acknowledge ${requestId}`;
1037
+ }
1038
+ /**
1039
+ * The acknowledgement is owed either way; only its text is conditional. So the
1040
+ * instruction never says "you may skip this" — it says what to run.
1041
+ */
1042
+ function acknowledgementDemand(textRequired) {
1043
+ return textRequired
1044
+ ? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
1045
+ : ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words';
895
1046
  }
896
1047
  function acknowledgementContext(answered) {
897
1048
  const due = answered.filter((entry) => entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined);
@@ -899,17 +1050,20 @@ function acknowledgementContext(answered) {
899
1050
  return ' Agent Acknowledgement is not required for the answered request(s).';
900
1051
  }
901
1052
  if (due.length === 1) {
902
- const requestId = due[0].pending.request_id;
1053
+ const entry = due[0];
1054
+ const requestId = entry.pending.request_id;
1055
+ const textRequired = entry.agent_acknowledgement_text_required !== false;
903
1056
  return (` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
904
- `\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`);
1057
+ `\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`);
905
1058
  }
1059
+ const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false);
906
1060
  const commands = due
907
1061
  .map((entry) => {
908
1062
  const requestId = entry.pending.request_id;
909
- return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``;
1063
+ return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``;
910
1064
  })
911
1065
  .join('\n');
912
- return (` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below with non-empty text saying what concrete work you will do because of that reply; a bare acknowledgement is insufficient:\n` +
1066
+ 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` +
913
1067
  commands);
914
1068
  }
915
1069
  function answersContext(answered, remaining) {
@@ -957,7 +1111,11 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
957
1111
  if (answer.agent_acknowledgement_required === true &&
958
1112
  requestId !== undefined &&
959
1113
  !acknowledgementDue.some((entry) => entry.request_id === requestId)) {
960
- acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at });
1114
+ acknowledgementDue.push({
1115
+ request_id: requestId,
1116
+ recorded_at: accepted.recorded_at,
1117
+ text_required: answer.agent_acknowledgement_text_required !== false,
1118
+ });
961
1119
  }
962
1120
  }
963
1121
  const next = {
@@ -999,10 +1157,69 @@ export function clearAcknowledgementObligation(sessionId, env, requestId) {
999
1157
  }
1000
1158
  function acknowledgementBlockContext(due) {
1001
1159
  const commands = due
1002
- .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
1160
+ .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``)
1003
1161
  .join('\n');
1162
+ const anyTextRequired = due.some((entry) => entry.text_required !== false);
1004
1163
  return (`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
1005
- `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'} with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient:\n${commands}`);
1164
+ `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`);
1165
+ }
1166
+ /**
1167
+ * How many turns in a row a session may be held waiting for an acknowledgement.
1168
+ *
1169
+ * The gate is the one place hooks deliberately break the fail-open rule, and it
1170
+ * had no bound at all: an agent that could not or would not acknowledge — or a
1171
+ * server that stayed unreachable, since an error here counts as unresolved —
1172
+ * held every turn of that session for ever. Blocking a user's agent
1173
+ * indefinitely does not get them an acknowledgement; it costs them the agent as
1174
+ * well as the acknowledgement.
1175
+ */
1176
+ const MAX_ACKNOWLEDGEMENT_BLOCKS = 3;
1177
+ /**
1178
+ * Hold the turn for an outstanding acknowledgement, or give up and let it
1179
+ * through once holding has stopped being worth its cost.
1180
+ *
1181
+ * Returns the outcome that blocks the turn, or `null` to carry on.
1182
+ */
1183
+ function holdForAcknowledgement(ctx, sessionId, due, notes) {
1184
+ if (due.length === 0)
1185
+ return null;
1186
+ const requestIds = due.map((entry) => entry.request_id);
1187
+ const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1;
1188
+ if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
1189
+ // Drop the obligation with the reason recorded. The answer was already
1190
+ // delivered; what is lost is the agent's receipt for it, and the log is
1191
+ // where that loss stays visible.
1192
+ for (const requestId of requestIds)
1193
+ clearAcknowledgementObligation(sessionId, ctx.env, requestId);
1194
+ resetAcknowledgementBlocks(sessionId, ctx.env);
1195
+ gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
1196
+ request_ids: requestIds,
1197
+ blocks: blocks - 1,
1198
+ limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
1199
+ });
1200
+ notes.push(`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`);
1201
+ return null;
1202
+ }
1203
+ updateSessionState(sessionId, ctx.env, (current) => ({
1204
+ ...current,
1205
+ acknowledgement_blocks: blocks,
1206
+ }));
1207
+ gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks });
1208
+ return {
1209
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1210
+ notes,
1211
+ log: { stage: 'acknowledgement-required', request_ids: requestIds },
1212
+ };
1213
+ }
1214
+ /** A turn that was not held resets the streak; only consecutive holds count. */
1215
+ function resetAcknowledgementBlocks(sessionId, env) {
1216
+ updateSessionState(sessionId, env, (current) => {
1217
+ if (current.acknowledgement_blocks === undefined)
1218
+ return current;
1219
+ const next = { ...current };
1220
+ delete next.acknowledgement_blocks;
1221
+ return next;
1222
+ });
1006
1223
  }
1007
1224
  async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
1008
1225
  const unresolved = [];
@@ -1040,9 +1257,9 @@ async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
1040
1257
  * grows with each settled generation, so the cap that bounds chained
1041
1258
  * answer-to-question loops is a real count rather than a constant 1.
1042
1259
  */
1043
- function settleAcceptedAnswers(ctx, sessionId, accepted) {
1260
+ function settleAcceptedAnswers(ctx, sessionId, accepted, cwd) {
1044
1261
  const retirements = accepted.answers
1045
- .map(({ pending }) => retiringQuestion(pending, 'answered'))
1262
+ .map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
1046
1263
  .filter((entry) => entry !== null);
1047
1264
  updateSessionState(sessionId, ctx.env, (current) => {
1048
1265
  const retiring = [...(current.retiring ?? [])];
@@ -1066,7 +1283,7 @@ async function retirePendings(ctx, envelope, sessionId, entries, state) {
1066
1283
  updateSessionState(sessionId, ctx.env, (current) => {
1067
1284
  const retiring = [...(current.retiring ?? [])];
1068
1285
  for (const entry of entries) {
1069
- const retirement = retiringQuestion(entry, state);
1286
+ const retirement = retiringQuestion(entry, state, envelope.cwd);
1070
1287
  if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1071
1288
  retiring.push(retirement);
1072
1289
  }
@@ -1132,6 +1349,8 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1132
1349
  reply: replies.at(-1),
1133
1350
  agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1134
1351
  entry.agent_acknowledgement_required,
1352
+ agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
1353
+ entry.agent_acknowledgement_text_required,
1135
1354
  };
1136
1355
  });
1137
1356
  if (answered.length > 0) {
@@ -1153,27 +1372,32 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1153
1372
  const retiring = [...(current.retiring ?? [])];
1154
1373
  const unasked = pendingList(current).filter((entry) => entry.request_id === undefined);
1155
1374
  for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
1156
- const retirement = retiringQuestion(entry, 'answered_elsewhere');
1375
+ const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
1157
1376
  if (retirement !== null &&
1158
1377
  !retiring.some((parked) => parked.request_id === retirement.request_id)) {
1159
1378
  retiring.push(retirement);
1160
1379
  }
1161
1380
  }
1162
- return {
1163
- last_prompt_at: ctx.now(),
1164
- ...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
1165
- ...(unasked.length > 0 ? { pending: unasked } : {}),
1166
- ...(retiring.length > 0 ? { retiring } : {}),
1167
- ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1168
- // The user typed, so whatever chain of answer-driven continuations was
1169
- // running, a human has taken the turn and the consecutive count starts
1170
- // over. Only a real prompt reaches here: the journal branch above returns
1171
- // first, and a wake route's injected message always arrives while its own
1172
- // answer is still journaled, so it can never pass for the user's presence.
1173
- ...(current.continuation === undefined
1174
- ? {}
1175
- : { continuation: { ...current.continuation, count: 0 } }),
1176
- };
1381
+ // Start from the whole document so fields introduced by a newer CLI remain
1382
+ // attached while this older writer applies the prompt transition it knows.
1383
+ const next = { ...current, last_prompt_at: ctx.now() };
1384
+ if (unasked.length > 0)
1385
+ next.pending = unasked;
1386
+ else
1387
+ delete next.pending;
1388
+ if (retiring.length > 0)
1389
+ next.retiring = retiring;
1390
+ else
1391
+ delete next.retiring;
1392
+ // The user typed, so whatever chain of answer-driven continuations was
1393
+ // running, a human has taken the turn and the consecutive count starts
1394
+ // over. Only a real prompt reaches here: the journal branch above returns
1395
+ // first, and a wake route's injected message always arrives while its own
1396
+ // answer is still journaled, so it can never pass for the user's presence.
1397
+ if (current.continuation !== undefined) {
1398
+ next.continuation = { ...current.continuation, count: 0 };
1399
+ }
1400
+ return next;
1177
1401
  });
1178
1402
  // The bridge that lets a plain `notifai ask` find the hook's canonical
1179
1403
  // session: an agent shell command gets no hook payload, and not every
@@ -1278,43 +1502,24 @@ export async function runEscalationWaiter(ctx, options) {
1278
1502
  if (!deliveryProven) {
1279
1503
  for (const answer of accepted.answers)
1280
1504
  reportAnswer(ctx, notes, answer, true);
1281
- return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes);
1505
+ return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes, envelope.cwd);
1282
1506
  }
1283
- settleAcceptedAnswers(ctx, sessionId, accepted);
1507
+ settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd);
1284
1508
  settledAnswerThisPass = true;
1285
1509
  state = readSessionState(sessionId, ctx.env);
1286
1510
  const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
1287
1511
  const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
1288
- if (due.length > 0) {
1289
- gate(ctx, 'held', 'acknowledgement-required', {
1290
- request_ids: due.map((entry) => entry.request_id),
1291
- });
1292
- return {
1293
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1294
- notes,
1295
- log: {
1296
- stage: 'acknowledgement-required',
1297
- request_ids: due.map((entry) => entry.request_id),
1298
- },
1299
- };
1300
- }
1512
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes);
1513
+ if (held !== null)
1514
+ return held;
1301
1515
  state = readSessionState(sessionId, ctx.env);
1302
1516
  }
1303
1517
  if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
1304
1518
  const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due);
1305
- if (due.length > 0) {
1306
- gate(ctx, 'held', 'acknowledgement-required', {
1307
- request_ids: due.map((entry) => entry.request_id),
1308
- });
1309
- return {
1310
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1311
- notes,
1312
- log: {
1313
- stage: 'acknowledgement-required',
1314
- request_ids: due.map((entry) => entry.request_id),
1315
- },
1316
- };
1317
- }
1519
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes);
1520
+ if (held !== null)
1521
+ return held;
1522
+ resetAcknowledgementBlocks(sessionId, ctx.env);
1318
1523
  state = readSessionState(sessionId, ctx.env);
1319
1524
  }
1320
1525
  const pending = pendingList(state);
@@ -1364,9 +1569,21 @@ export function hookContinuationRoute() {
1364
1569
  * consults is not a backstop: that is how a single answer once reached a
1365
1570
  * session 250 times.
1366
1571
  */
1367
- async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
1572
+ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cwd) {
1368
1573
  const { answers: answered, remaining } = accepted;
1369
1574
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
1575
+ const held = accepted.held_deliveries ?? 0;
1576
+ if (held >= MAX_HELD_DELIVERIES) {
1577
+ gate(ctx, 'held', 'delivery-limit', {
1578
+ route: route.kind,
1579
+ held,
1580
+ limit: MAX_HELD_DELIVERIES,
1581
+ request_ids: requestIds,
1582
+ });
1583
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
1584
+ notes.push(`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`);
1585
+ return { notes };
1586
+ }
1370
1587
  const attempt = (accepted.delivery_attempts ?? 0) + 1;
1371
1588
  if (attempt > MAX_CONTINUATION_COUNT) {
1372
1589
  gate(ctx, 'held', 'delivery-limit', {
@@ -1378,7 +1595,7 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
1378
1595
  // Settle rather than journal for ever. The answer has been offered as often
1379
1596
  // as any working route could need, it is already in the notes of each of
1380
1597
  // those turns, and the questions it answered still deserve retirement.
1381
- settleAcceptedAnswers(ctx, sessionId, accepted);
1598
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
1382
1599
  notes.push(`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`);
1383
1600
  return { notes };
1384
1601
  }
@@ -1414,6 +1631,18 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
1414
1631
  delivered_route: deliveredRoute,
1415
1632
  }));
1416
1633
  }
1634
+ else if (delivered.acknowledgement === 'held') {
1635
+ // Give the delivery attempt back and count the hold instead. The attempt
1636
+ // was counted before the call because one that dies mid-delivery still
1637
+ // happened and a counter written afterwards could be evaded by crashing —
1638
+ // but a route that *returns* `held` proves both that this process survived
1639
+ // and that nothing was handed over.
1640
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1641
+ ...current,
1642
+ delivery_attempts: accepted.delivery_attempts ?? 0,
1643
+ held_deliveries: (current.held_deliveries ?? 0) + 1,
1644
+ }));
1645
+ }
1417
1646
  ctx.log?.info('hook.answer', {
1418
1647
  answered: true,
1419
1648
  stage: deliveredStage,
@@ -1442,7 +1671,11 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1442
1671
  for (const entry of live)
1443
1672
  await closeQuietly(ctx, entry.request_id);
1444
1673
  await retirePendings(ctx, envelope, sessionId, pending, 'expired');
1445
- gate(ctx, 'held', 'no-question');
1674
+ // Not `no-question`: there was a question, and this says so. Reading the
1675
+ // log to find out why an ask never travelled is the whole reason these
1676
+ // reasons exist, and the wrong one sent that reader looking for a question
1677
+ // that was right there.
1678
+ gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness });
1446
1679
  notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
1447
1680
  return { notes };
1448
1681
  }
@@ -1470,6 +1703,8 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1470
1703
  reply: replies.at(-1),
1471
1704
  agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1472
1705
  entry.agent_acknowledgement_required,
1706
+ agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
1707
+ entry.agent_acknowledgement_text_required,
1473
1708
  };
1474
1709
  });
1475
1710
  const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
@@ -1477,7 +1712,7 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1477
1712
  reportAnswer(ctx, notes, answer, true);
1478
1713
  // Anything still unasked rides the next Stop: the agent is being resumed
1479
1714
  // with answers right now, and may not even need the rest afterwards.
1480
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
1715
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1481
1716
  }
1482
1717
  const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
1483
1718
  liveToEscalate = recoverableLive;
@@ -1561,16 +1796,26 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1561
1796
  // its own notification — one ask never stands in for another.
1562
1797
  const submitted = [];
1563
1798
  for (const entry of unasked) {
1564
- // The reply window is the question's own: whatever is left of the waiter's
1565
- // ceiling, granted to the server so it stops accepting answers at the same
1566
- // moment this owner stops listening for them.
1567
- const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000);
1568
- if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
1569
- notes.push('too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal');
1799
+ // How long the answer is accepted, and how long this owner listens for it,
1800
+ // are two different spans. They used to be one: the window was whatever
1801
+ // remained of the waiter's ceiling, so a question asked at a turn's end
1802
+ // stopped being answerable about eight minutes later, whatever the user
1803
+ // had configured.
1804
+ //
1805
+ // That was right when the waiter was the only way an answer came back. It
1806
+ // is not any more: a late answer is found by the next turn's poll or
1807
+ // replayed from the journal, so this owner giving up listening says
1808
+ // nothing about whether the user's answer is still wanted.
1809
+ const replyWindowSeconds = ctx.config.reply_window_seconds.value;
1810
+ if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
1811
+ notes.push('too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal');
1570
1812
  continue;
1571
1813
  }
1814
+ // The owner still stops listening at its own ceiling. Only the span the
1815
+ // server accepts answers into changed.
1572
1816
  const ownerDeadlineAt = ceilingAt;
1573
1817
  const questions = pendingQuestions(entry);
1818
+ const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
1574
1819
  let intent = entry.submission;
1575
1820
  if (intent === undefined) {
1576
1821
  if (entry.body === undefined) {
@@ -1584,7 +1829,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1584
1829
  questions,
1585
1830
  ...(entry.media !== undefined ? { media: entry.media } : {}),
1586
1831
  ...(entry.project !== undefined ? { project: entry.project } : {}),
1587
- ...(entry.source !== undefined ? { source: entry.source } : {}),
1832
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
1588
1833
  event: 'agent_question',
1589
1834
  windowSeconds: replyWindowSeconds,
1590
1835
  ownerDeadlineAt,
@@ -1639,12 +1884,77 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1639
1884
  receipt = await submitQuestion(ctx, intent);
1640
1885
  admissionConfirmed = true;
1641
1886
  }
1642
- catch (err) {
1643
- if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1644
- // A concrete client response is an admission rejection, not an
1645
- // ambiguous commit. Keep the frozen intent for a later retry and leave
1646
- // the question in the terminal; polling a request the server says it
1647
- // did not accept only burns the owner window and hides the real fault.
1887
+ catch (caught) {
1888
+ let err = caught;
1889
+ if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
1890
+ ctx.log?.error('hook.pushed', {
1891
+ ok: false,
1892
+ request_id: intent.request_id,
1893
+ status: err.status,
1894
+ code: err.code,
1895
+ message: err.message,
1896
+ });
1897
+ if (entry.submission !== undefined) {
1898
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
1899
+ clearFrozenSubmission(sessionId, ctx.env, entry);
1900
+ if (entry.body === undefined) {
1901
+ dropPendingQuestion(sessionId, ctx.env, entry);
1902
+ notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it');
1903
+ continue;
1904
+ }
1905
+ const reminted = await prepareQuestionSubmission(ctx, {
1906
+ title: questions[0].text,
1907
+ body: entry.body,
1908
+ questions,
1909
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
1910
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
1911
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
1912
+ event: 'agent_question',
1913
+ windowSeconds: replyWindowSeconds,
1914
+ ownerDeadlineAt,
1915
+ });
1916
+ if ('error' in reminted) {
1917
+ dropPendingQuestion(sessionId, ctx.env, entry);
1918
+ notes.push(reminted.error);
1919
+ continue;
1920
+ }
1921
+ intent = reminted;
1922
+ updateSessionState(sessionId, ctx.env, (current) => {
1923
+ const list = pendingList(current);
1924
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1925
+ if (index < 0)
1926
+ return current;
1927
+ const next = [...list];
1928
+ next[index] = { ...next[index], submission: reminted };
1929
+ return { ...current, pending: next };
1930
+ });
1931
+ try {
1932
+ receipt = await submitQuestion(ctx, intent);
1933
+ admissionConfirmed = true;
1934
+ }
1935
+ catch (retryErr) {
1936
+ if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
1937
+ dropPendingQuestion(sessionId, ctx.env, entry);
1938
+ notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
1939
+ ctx.log?.error('hook.pushed', {
1940
+ ok: false,
1941
+ request_id: intent.request_id,
1942
+ status: retryErr.status,
1943
+ code: retryErr.code,
1944
+ message: retryErr.message,
1945
+ });
1946
+ continue;
1947
+ }
1948
+ err = retryErr;
1949
+ }
1950
+ }
1951
+ else {
1952
+ dropPendingQuestion(sessionId, ctx.env, entry);
1953
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
1954
+ continue;
1955
+ }
1956
+ }
1957
+ if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1648
1958
  notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
1649
1959
  ctx.log?.error('hook.pushed', {
1650
1960
  ok: false,
@@ -1655,11 +1965,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1655
1965
  });
1656
1966
  continue;
1657
1967
  }
1658
- // This is deliberately not a fail-open return. A transport failure is
1659
- // ambiguous: the server may already have dispatched the card. The
1660
- // client-generated request id keeps polling/finalization possible.
1661
- notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1662
- ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1968
+ if (!admissionConfirmed) {
1969
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1970
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1971
+ }
1663
1972
  }
1664
1973
  if (admissionConfirmed) {
1665
1974
  ctx.log?.info('hook.pushed', {
@@ -1675,17 +1984,28 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1675
1984
  : Date.parse(receipt.reply_expires_at);
1676
1985
  const live = {
1677
1986
  ...entry,
1987
+ ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
1678
1988
  request_id: intent.request_id,
1679
1989
  collapse_key: intent.collapse_key,
1680
1990
  device_ids: intent.device_ids,
1681
- // Submission and every later poll share one wall-clock owner. The
1682
- // server may start its relative window a little later while the submit
1683
- // response is in flight; closing at this deadline is therefore the
1684
- // authoritative backstop against accepting an answer into a void.
1685
- reply_deadline_at: Math.min(intent.owner_deadline_at, Number.isFinite(committedReplyDeadline)
1991
+ // When the server stops accepting an answer — not when this owner stops
1992
+ // waiting for one. Those were the same number until the window became a
1993
+ // setting, and taking the minimum silently capped every question at the
1994
+ // waiter's ceiling: a question asked with a day-long window was swept as
1995
+ // stale about eight minutes later and retired, so the answer given over
1996
+ // lunch met a closed question.
1997
+ //
1998
+ // Both consumers of this field already clamp to the owner's own ceiling
1999
+ // (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
2000
+ // `Math.min(ceilingAt, …)` when deriving the wait), so recording the
2001
+ // real expiry here lengthens what the user gets without lengthening what
2002
+ // this process waits.
2003
+ reply_deadline_at: Number.isFinite(committedReplyDeadline)
1686
2004
  ? committedReplyDeadline
1687
- : intent.owner_deadline_at),
2005
+ : intent.owner_deadline_at,
1688
2006
  };
2007
+ if (intent.draft.source === undefined)
2008
+ delete live.source;
1689
2009
  delete live.submission;
1690
2010
  submitted.push(live);
1691
2011
  // Record what is now live on the user's devices BEFORE any wait. If we
@@ -1705,7 +2025,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1705
2025
  // The entry vanished while the submit was in flight (the user's prompt
1706
2026
  // wiped the queue). The delivered notification must still be retirable,
1707
2027
  // so park it rather than lose its only identifiers.
1708
- const retirement = retiringQuestion(live, 'answered_elsewhere');
2028
+ const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
1709
2029
  const retiring = [...(current.retiring ?? [])];
1710
2030
  if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
1711
2031
  retiring.push(retirement);
@@ -1735,7 +2055,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1735
2055
  const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
1736
2056
  for (const answer of staleAnswers)
1737
2057
  reportAnswer(ctx, notes, answer, true);
1738
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
2058
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1739
2059
  }
1740
2060
  const waitingOn = [
1741
2061
  ...alreadyLive.filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now()),
@@ -1751,10 +2071,32 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1751
2071
  if (permanentFailure !== null)
1752
2072
  notes.push(permanentFailure);
1753
2073
  if (waited.byRequest.size === 0) {
1754
- // Once this owner returns, no shipped command-hook harness can guarantee
1755
- // a later answer reaches the same agent turn. Close first, then retire the
1756
- // local records, so the Companion never accepts an answer into a void.
1757
- const finalized = await finalizePendings(ctx, waitingOn);
2074
+ // This owner is done listening. That used to mean the question was over:
2075
+ // it closed the window server-side and retired the record, on the reasoning
2076
+ // that no later answer could reach the same agent turn.
2077
+ //
2078
+ // The reaching was never the point — the *accepting* is. A question whose
2079
+ // window is still open is polled again by the next prompt and the next
2080
+ // Stop, and an answer found there is journaled and replayed. Closing here
2081
+ // capped every question at this owner's ceiling however long a window the
2082
+ // user had configured, which is what made a day-long window mean eight
2083
+ // minutes.
2084
+ //
2085
+ // So: finalize only what the server has genuinely stopped accepting, and
2086
+ // leave the rest live for the turn that comes next. A session that truly
2087
+ // ends still retires its questions — that is what the session-end hook is
2088
+ // for, and it is the honest place for it.
2089
+ const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
2090
+ entry.reply_deadline_at <= ctx.now() ||
2091
+ // A permanent rejection is the server saying this question can never be
2092
+ // answered, whatever its window says. Leaving it open would strand a
2093
+ // dead question on the user's devices.
2094
+ waited.permanentFailures.has(entry.request_id));
2095
+ const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry));
2096
+ if (stillAnswerable.length > 0) {
2097
+ notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`);
2098
+ }
2099
+ const finalized = await finalizePendings(ctx, expired);
1758
2100
  const finalAnswers = finalized
1759
2101
  .map(finalizedAnswer)
1760
2102
  .filter((entry) => entry !== null);
@@ -1773,7 +2115,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1773
2115
  const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
1774
2116
  for (const answer of finalAnswers)
1775
2117
  reportAnswer(ctx, notes, answer, false);
1776
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
2118
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1777
2119
  }
1778
2120
  const requestIdSummary = summarizeRequestIds(waitingOn);
1779
2121
  ctx.log?.info('hook.answer', {
@@ -1809,6 +2151,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1809
2151
  reply: authoritative.at(-1),
1810
2152
  replies: authoritative,
1811
2153
  agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
2154
+ agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
1812
2155
  });
1813
2156
  if (finalized.response === null) {
1814
2157
  notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
@@ -1817,7 +2160,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1817
2160
  const accepted = stageAcceptedAnswers(ctx, sessionId, answered, waitingOn.length - answered.length);
1818
2161
  for (const answer of answered)
1819
2162
  reportAnswer(ctx, notes, answer, false);
1820
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes);
2163
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
1821
2164
  }
1822
2165
  // ---------------------------------------------------------------------------
1823
2166
  // SessionEnd — local cleanup only
@@ -1841,7 +2184,7 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1841
2184
  const orphans = [...(state.retiring ?? [])];
1842
2185
  for (const entry of pendingList(state)) {
1843
2186
  try {
1844
- const orphan = retiringQuestion(entry, 'expired');
2187
+ const orphan = retiringQuestion(entry, 'expired', envelope.cwd);
1845
2188
  if (orphan !== null)
1846
2189
  orphans.push(orphan);
1847
2190
  }
@@ -1860,18 +2203,13 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1860
2203
  notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
1861
2204
  }
1862
2205
  if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
1863
- const preserved = {};
1864
- if (state.accepted !== undefined)
1865
- preserved.accepted = state.accepted;
1866
- if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
1867
- preserved.acknowledgement_due = state.acknowledgement_due;
2206
+ const preserved = { ...state };
2207
+ delete preserved.pending;
2208
+ delete preserved.retiring;
2209
+ delete preserved.acknowledgement_blocks;
2210
+ if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
2211
+ delete preserved.acknowledgement_due;
1868
2212
  }
1869
- if (state.last_prompt_at !== undefined)
1870
- preserved.last_prompt_at = state.last_prompt_at;
1871
- if (state.last_stop_at !== undefined)
1872
- preserved.last_stop_at = state.last_stop_at;
1873
- if (state.continuation !== undefined)
1874
- preserved.continuation = state.continuation;
1875
2213
  writeSessionState(sessionId, env, preserved);
1876
2214
  notes.push(state.accepted !== undefined
1877
2215
  ? 'preserved an accepted device answer for this exact session to resume'
@@ -1893,12 +2231,21 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1893
2231
  // ask — register a question for the turn's end
1894
2232
  // ---------------------------------------------------------------------------
1895
2233
  /**
1896
- * More pending questions than this means an agent is looping, not asking.
1897
- * Related questions belong in one `ask --form` (the wire carries up to four
1898
- * questions per notification); four separate pushes is already a lot of lock
1899
- * screen.
2234
+ * Registrations still waiting for their first push. More than this in one turn
2235
+ * means an agent is looping, not asking, and related questions belong in one
2236
+ * `ask --form` instead.
2237
+ *
2238
+ * Deliberately counts only unasked entries. A question already on the user's
2239
+ * devices is waiting on a person, not on the agent, and an answer stays
2240
+ * accepted for a day — so counting live questions here would have turned a
2241
+ * patient user into a reason the agent could no longer ask anything.
1900
2242
  */
1901
2243
  export const MAX_PENDING_QUESTIONS = 4;
2244
+ /**
2245
+ * Everything this session has open at once, asked or not. The lock screen is
2246
+ * the shared resource being protected here, not the agent's turn.
2247
+ */
2248
+ export const MAX_LIVE_QUESTIONS = 10;
1902
2249
  /**
1903
2250
  * A session may hold several registered questions at once: a new `ask` never
1904
2251
  * ends an earlier one. Each reaches the user as its own notification and is
@@ -1912,11 +2259,16 @@ export const MAX_PENDING_QUESTIONS = 4;
1912
2259
  * running in one project at once, and their questions must never interfere.
1913
2260
  */
1914
2261
  export function registerQuestion(sessionId, env, question, now = Date.now()) {
1915
- let full = false;
2262
+ let full = null;
1916
2263
  updateSessionState(sessionId, env, (state) => {
1917
2264
  const pending = pendingList(state);
1918
- if (pending.length >= MAX_PENDING_QUESTIONS) {
1919
- full = true;
2265
+ const unasked = pending.filter((entry) => entry.request_id === undefined);
2266
+ if (unasked.length >= MAX_PENDING_QUESTIONS) {
2267
+ full = 'unasked';
2268
+ return state;
2269
+ }
2270
+ if (pending.length >= MAX_LIVE_QUESTIONS) {
2271
+ full = 'live';
1920
2272
  return state;
1921
2273
  }
1922
2274
  return {
@@ -1932,10 +2284,14 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
1932
2284
  ],
1933
2285
  };
1934
2286
  });
1935
- if (full) {
2287
+ if (full === 'unasked') {
1936
2288
  throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
1937
2289
  'Combine related questions into one `notifai ask --form` instead of registering more.');
1938
2290
  }
2291
+ if (full === 'live') {
2292
+ throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
2293
+ 'Retire the ones you no longer need with `notifai close <request_id>` before asking another.');
2294
+ }
1939
2295
  }
1940
2296
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
1941
2297
  export function parseHookInput(raw) {