@raidiant/notifai 4.0.0 → 5.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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +72 -5
  17. package/dist/hooks.js +407 -79
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
package/dist/hooks.js CHANGED
@@ -1,6 +1,7 @@
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';
@@ -8,12 +9,15 @@ import { atomicWriteFileSync } from './atomic-file.js';
8
9
  import { withFileLock } from './file-lock.js';
9
10
  import { HARNESS_CAPABILITIES, HARNESSES, } from './harnesses.js';
10
11
  /**
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.
12
+ * A retirement is only worth syncing while the question it retires could still
13
+ * be answered, so this has to outlive the longest reply window a user can
14
+ * choose — it is derived from that bound rather than from a guess about how
15
+ * long questions stay open, which is exactly the assumption that broke when
16
+ * the window stopped being one hour. Past it the companion shows the question
17
+ * as dead on next open anyway. Also the backstop that keeps an unreachable
18
+ * server from growing the queue for ever.
15
19
  */
16
- const ORPHAN_TTL_MS = 24 * 3600 * 1000;
20
+ const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000;
17
21
  /** More orphans than this means something is looping; keep the newest. */
18
22
  const ORPHAN_QUEUE_CAP = 50;
19
23
  function summarizeRequestIds(entries) {
@@ -30,12 +34,33 @@ function summarizeRequestIds(entries) {
30
34
  * question is only how long that host is willing to wait in total.
31
35
  */
32
36
  export const WAITER_CEILING_SECONDS = 480;
37
+ /**
38
+ * The same wall clock where the waiter runs in the background instead of
39
+ * inside a held turn.
40
+ *
41
+ * On a host that holds its turn, 480 s is a real cost to the person sitting in
42
+ * front of it, and the number above is right. On Claude Code the Stop hook
43
+ * returns at once and the waiter is detached, so the same 480 s buys nothing
44
+ * and costs a direct wake: an answer arriving at minute nine had to wait for
45
+ * the session's next turn even though nothing was blocked.
46
+ *
47
+ * This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
48
+ * because that declaration's kill is silent, and under the reply window the
49
+ * question was pushed with. Past it the journal still delivers at the next
50
+ * turn — the ceiling decides how long a *direct* wake stays possible, never
51
+ * whether the answer survives.
52
+ */
53
+ export const DETACHED_WAITER_CEILING_SECONDS = 3300;
54
+ /** The waiter's wall clock, which depends on whether a turn is held open for it. */
55
+ export function waiterCeilingSeconds(detached) {
56
+ return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS;
57
+ }
33
58
  /**
34
59
  * The wire contract's shortest reply window. A question submitted with less
35
60
  * than this is rejected outright, and accepting one would in any case let the
36
61
  * server go on taking an answer after the waiter that owns it has returned.
37
62
  */
38
- const MIN_REPLY_WINDOW_SECONDS = 60;
63
+ export const MIN_REPLY_WINDOW_SECONDS = 60;
39
64
  /**
40
65
  * The terminal-first wait: the question sits in the terminal for
41
66
  * `ask_grace_seconds` from when it was sent, and only then reaches companion
@@ -168,6 +193,16 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
168
193
  * no trustworthy PID fall back to an age limit.
169
194
  */
170
195
  const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
196
+ /**
197
+ * How long a crashed claim *guard* blocks the next hook.
198
+ *
199
+ * The claim itself may legitimately be held for the whole waiter, so it takes
200
+ * that ceiling. The guard is a short lock around the claim's own bookkeeping —
201
+ * a few file operations — and borrowing the waiter's ceiling for it meant a
202
+ * hook killed at exactly the wrong moment wedged this session's Stop path for
203
+ * eight minutes, for a critical section that never runs longer than a second.
204
+ */
205
+ const CLAIM_GUARD_TTL_MS = 30_000;
171
206
  const heldClaims = new Map();
172
207
  export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
173
208
  const file = claimPath(sessionId, env);
@@ -266,7 +301,7 @@ function acquireClaimGuard(file) {
266
301
  if (err.code !== 'EEXIST')
267
302
  return false;
268
303
  try {
269
- if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS)
304
+ if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS)
270
305
  return false;
271
306
  }
272
307
  catch {
@@ -400,6 +435,30 @@ function clearMatchingProjectSession(cwd, env, sessionId) {
400
435
  })}\n`);
401
436
  });
402
437
  }
438
+ /**
439
+ * Record one gate decision.
440
+ *
441
+ * `verdict` is what happened to the question and `reason` is the closed-set
442
+ * name of the rule that decided it, so an agent can ask "why was it held" with
443
+ * a filter rather than by matching on prose. The prose still exists — it is what
444
+ * the user sees in the terminal — but it is free to be rewritten, and a log a
445
+ * filter cannot rely on is a log nobody filters.
446
+ */
447
+ export const GATE_REASONS = [
448
+ 'no-session',
449
+ 'no-question',
450
+ 'answered',
451
+ 'continuation-repeat',
452
+ 'continuation-limit',
453
+ 'delivery-limit',
454
+ 'acknowledgement-required',
455
+ 'acknowledgement-abandoned',
456
+ 'harness-cannot-continue',
457
+ 'notifications-off',
458
+ 'claimed-elsewhere',
459
+ 'elapsed',
460
+ 'proceeding',
461
+ ];
403
462
  function gate(ctx, verdict, reason, data = {}) {
404
463
  ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data });
405
464
  }
@@ -440,7 +499,7 @@ async function prepareQuestionSubmission(ctx, options) {
440
499
  ...(options.media !== undefined ? { media: options.media } : {}),
441
500
  device: answerable,
442
501
  reply: true,
443
- replyWindow: Math.max(60, options.windowSeconds),
502
+ replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
444
503
  questions: options.questions,
445
504
  collapseKey,
446
505
  }, options.source === undefined ? {} : { source: options.source });
@@ -764,6 +823,20 @@ const LATE_PROMPT_POLL_SECONDS = 3;
764
823
  * produce.
765
824
  */
766
825
  export const MAX_CONTINUATION_COUNT = 3;
826
+ /**
827
+ * How many turns an answer may be *held* before it is settled unread.
828
+ *
829
+ * A hold is not a delivery: the route reported that it handed nothing over, so
830
+ * the agent has not seen the answer and the wake-loop the continuation cap
831
+ * exists to stop has not happened. Counting holds against that cap meant three
832
+ * turns where a liveness probe could not reach a busy session were enough to
833
+ * discard an answer the user had already given.
834
+ *
835
+ * It still has to end. A route that can never deliver would otherwise retry
836
+ * every turn for the life of the session, so holds get their own, far looser
837
+ * bound — loose because each one costs nothing and the answer is still wanted.
838
+ */
839
+ export const MAX_HELD_DELIVERIES = 20;
767
840
  /** One bounded recovery poll for a question that outlived its original wait. */
768
841
  async function pollPendingReply(ctx, pending, timeoutSeconds) {
769
842
  if (pending.request_id === undefined) {
@@ -802,6 +875,7 @@ function finalizedAnswer(finalized) {
802
875
  reply,
803
876
  replies,
804
877
  agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
878
+ agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
805
879
  };
806
880
  }
807
881
  /** Persist and surface one answer without letting the two representations drift. */
@@ -848,6 +922,38 @@ function isSamePending(a, b) {
848
922
  }
849
923
  return a.question === b.question && a.asked_at === b.asked_at;
850
924
  }
925
+ /**
926
+ * The frozen draft itself will never be accepted. Replaying it forever hides a
927
+ * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
928
+ * Auth, conflicts, and timeouts are not this class.
929
+ */
930
+ function isTerminalDraftRejection(err) {
931
+ return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
932
+ }
933
+ function dropPendingQuestion(sessionId, env, entry) {
934
+ updateSessionState(sessionId, env, (current) => {
935
+ const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
936
+ const next = { ...current };
937
+ if (pending.length > 0)
938
+ next.pending = pending;
939
+ else
940
+ delete next.pending;
941
+ return next;
942
+ });
943
+ }
944
+ function clearFrozenSubmission(sessionId, env, entry) {
945
+ updateSessionState(sessionId, env, (current) => {
946
+ const list = pendingList(current);
947
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
948
+ if (index < 0)
949
+ return current;
950
+ const next = [...list];
951
+ const copy = { ...next[index] };
952
+ delete copy.submission;
953
+ next[index] = copy;
954
+ return { ...current, pending: next };
955
+ });
956
+ }
851
957
  /** The question set this pending record pushes, however it was registered. */
852
958
  function pendingQuestions(pending) {
853
959
  return pending.questions ?? [{ id: 'q1', text: pending.question }];
@@ -890,8 +996,19 @@ function answerContext(answered) {
890
996
  * tied to the question that asked it, with a truthful note about anything
891
997
  * still waiting.
892
998
  */
893
- function acknowledgementCommand(requestId) {
894
- return `notifai acknowledge ${requestId} --text <text>`;
999
+ function acknowledgementCommand(requestId, textRequired = true) {
1000
+ return textRequired
1001
+ ? `notifai acknowledge ${requestId} --text <text>`
1002
+ : `notifai acknowledge ${requestId}`;
1003
+ }
1004
+ /**
1005
+ * The acknowledgement is owed either way; only its text is conditional. So the
1006
+ * instruction never says "you may skip this" — it says what to run.
1007
+ */
1008
+ function acknowledgementDemand(textRequired) {
1009
+ return textRequired
1010
+ ? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
1011
+ : ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words';
895
1012
  }
896
1013
  function acknowledgementContext(answered) {
897
1014
  const due = answered.filter((entry) => entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined);
@@ -899,17 +1016,20 @@ function acknowledgementContext(answered) {
899
1016
  return ' Agent Acknowledgement is not required for the answered request(s).';
900
1017
  }
901
1018
  if (due.length === 1) {
902
- const requestId = due[0].pending.request_id;
1019
+ const entry = due[0];
1020
+ const requestId = entry.pending.request_id;
1021
+ const textRequired = entry.agent_acknowledgement_text_required !== false;
903
1022
  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.`);
1023
+ `\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`);
905
1024
  }
1025
+ const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false);
906
1026
  const commands = due
907
1027
  .map((entry) => {
908
1028
  const requestId = entry.pending.request_id;
909
- return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``;
1029
+ return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``;
910
1030
  })
911
1031
  .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` +
1032
+ 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
1033
  commands);
914
1034
  }
915
1035
  function answersContext(answered, remaining) {
@@ -957,7 +1077,11 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
957
1077
  if (answer.agent_acknowledgement_required === true &&
958
1078
  requestId !== undefined &&
959
1079
  !acknowledgementDue.some((entry) => entry.request_id === requestId)) {
960
- acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at });
1080
+ acknowledgementDue.push({
1081
+ request_id: requestId,
1082
+ recorded_at: accepted.recorded_at,
1083
+ text_required: answer.agent_acknowledgement_text_required !== false,
1084
+ });
961
1085
  }
962
1086
  }
963
1087
  const next = {
@@ -999,10 +1123,69 @@ export function clearAcknowledgementObligation(sessionId, env, requestId) {
999
1123
  }
1000
1124
  function acknowledgementBlockContext(due) {
1001
1125
  const commands = due
1002
- .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
1126
+ .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``)
1003
1127
  .join('\n');
1128
+ const anyTextRequired = due.some((entry) => entry.text_required !== false);
1004
1129
  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}`);
1130
+ `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`);
1131
+ }
1132
+ /**
1133
+ * How many turns in a row a session may be held waiting for an acknowledgement.
1134
+ *
1135
+ * The gate is the one place hooks deliberately break the fail-open rule, and it
1136
+ * had no bound at all: an agent that could not or would not acknowledge — or a
1137
+ * server that stayed unreachable, since an error here counts as unresolved —
1138
+ * held every turn of that session for ever. Blocking a user's agent
1139
+ * indefinitely does not get them an acknowledgement; it costs them the agent as
1140
+ * well as the acknowledgement.
1141
+ */
1142
+ const MAX_ACKNOWLEDGEMENT_BLOCKS = 3;
1143
+ /**
1144
+ * Hold the turn for an outstanding acknowledgement, or give up and let it
1145
+ * through once holding has stopped being worth its cost.
1146
+ *
1147
+ * Returns the outcome that blocks the turn, or `null` to carry on.
1148
+ */
1149
+ function holdForAcknowledgement(ctx, sessionId, due, notes) {
1150
+ if (due.length === 0)
1151
+ return null;
1152
+ const requestIds = due.map((entry) => entry.request_id);
1153
+ const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1;
1154
+ if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
1155
+ // Drop the obligation with the reason recorded. The answer was already
1156
+ // delivered; what is lost is the agent's receipt for it, and the log is
1157
+ // where that loss stays visible.
1158
+ for (const requestId of requestIds)
1159
+ clearAcknowledgementObligation(sessionId, ctx.env, requestId);
1160
+ resetAcknowledgementBlocks(sessionId, ctx.env);
1161
+ gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
1162
+ request_ids: requestIds,
1163
+ blocks: blocks - 1,
1164
+ limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
1165
+ });
1166
+ notes.push(`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`);
1167
+ return null;
1168
+ }
1169
+ updateSessionState(sessionId, ctx.env, (current) => ({
1170
+ ...current,
1171
+ acknowledgement_blocks: blocks,
1172
+ }));
1173
+ gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks });
1174
+ return {
1175
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1176
+ notes,
1177
+ log: { stage: 'acknowledgement-required', request_ids: requestIds },
1178
+ };
1179
+ }
1180
+ /** A turn that was not held resets the streak; only consecutive holds count. */
1181
+ function resetAcknowledgementBlocks(sessionId, env) {
1182
+ updateSessionState(sessionId, env, (current) => {
1183
+ if (current.acknowledgement_blocks === undefined)
1184
+ return current;
1185
+ const next = { ...current };
1186
+ delete next.acknowledgement_blocks;
1187
+ return next;
1188
+ });
1006
1189
  }
1007
1190
  async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
1008
1191
  const unresolved = [];
@@ -1132,6 +1315,8 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1132
1315
  reply: replies.at(-1),
1133
1316
  agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1134
1317
  entry.agent_acknowledgement_required,
1318
+ agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
1319
+ entry.agent_acknowledgement_text_required,
1135
1320
  };
1136
1321
  });
1137
1322
  if (answered.length > 0) {
@@ -1165,6 +1350,16 @@ export async function handleUserPromptSubmit(ctx, envelope) {
1165
1350
  ...(unasked.length > 0 ? { pending: unasked } : {}),
1166
1351
  ...(retiring.length > 0 ? { retiring } : {}),
1167
1352
  ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
1353
+ // Carried across a prompt on purpose. This rewrite rebuilds the state
1354
+ // from named fields, so anything omitted is silently erased — and an
1355
+ // erased obligation means the user never learns their reply was read,
1356
+ // which is the one thing acknowledgement exists to guarantee.
1357
+ ...(current.acknowledgement_due === undefined
1358
+ ? {}
1359
+ : { acknowledgement_due: current.acknowledgement_due }),
1360
+ ...(current.acknowledgement_blocks === undefined
1361
+ ? {}
1362
+ : { acknowledgement_blocks: current.acknowledgement_blocks }),
1168
1363
  // The user typed, so whatever chain of answer-driven continuations was
1169
1364
  // running, a human has taken the turn and the consecutive count starts
1170
1365
  // over. Only a real prompt reaches here: the journal branch above returns
@@ -1285,36 +1480,17 @@ export async function runEscalationWaiter(ctx, options) {
1285
1480
  state = readSessionState(sessionId, ctx.env);
1286
1481
  const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
1287
1482
  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
- }
1483
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes);
1484
+ if (held !== null)
1485
+ return held;
1301
1486
  state = readSessionState(sessionId, ctx.env);
1302
1487
  }
1303
1488
  if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
1304
1489
  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
- }
1490
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes);
1491
+ if (held !== null)
1492
+ return held;
1493
+ resetAcknowledgementBlocks(sessionId, ctx.env);
1318
1494
  state = readSessionState(sessionId, ctx.env);
1319
1495
  }
1320
1496
  const pending = pendingList(state);
@@ -1367,6 +1543,18 @@ export function hookContinuationRoute() {
1367
1543
  async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
1368
1544
  const { answers: answered, remaining } = accepted;
1369
1545
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
1546
+ const held = accepted.held_deliveries ?? 0;
1547
+ if (held >= MAX_HELD_DELIVERIES) {
1548
+ gate(ctx, 'held', 'delivery-limit', {
1549
+ route: route.kind,
1550
+ held,
1551
+ limit: MAX_HELD_DELIVERIES,
1552
+ request_ids: requestIds,
1553
+ });
1554
+ settleAcceptedAnswers(ctx, sessionId, accepted);
1555
+ notes.push(`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`);
1556
+ return { notes };
1557
+ }
1370
1558
  const attempt = (accepted.delivery_attempts ?? 0) + 1;
1371
1559
  if (attempt > MAX_CONTINUATION_COUNT) {
1372
1560
  gate(ctx, 'held', 'delivery-limit', {
@@ -1414,6 +1602,18 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
1414
1602
  delivered_route: deliveredRoute,
1415
1603
  }));
1416
1604
  }
1605
+ else if (delivered.acknowledgement === 'held') {
1606
+ // Give the delivery attempt back and count the hold instead. The attempt
1607
+ // was counted before the call because one that dies mid-delivery still
1608
+ // happened and a counter written afterwards could be evaded by crashing —
1609
+ // but a route that *returns* `held` proves both that this process survived
1610
+ // and that nothing was handed over.
1611
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
1612
+ ...current,
1613
+ delivery_attempts: accepted.delivery_attempts ?? 0,
1614
+ held_deliveries: (current.held_deliveries ?? 0) + 1,
1615
+ }));
1616
+ }
1417
1617
  ctx.log?.info('hook.answer', {
1418
1618
  answered: true,
1419
1619
  stage: deliveredStage,
@@ -1442,7 +1642,11 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1442
1642
  for (const entry of live)
1443
1643
  await closeQuietly(ctx, entry.request_id);
1444
1644
  await retirePendings(ctx, envelope, sessionId, pending, 'expired');
1445
- gate(ctx, 'held', 'no-question');
1645
+ // Not `no-question`: there was a question, and this says so. Reading the
1646
+ // log to find out why an ask never travelled is the whole reason these
1647
+ // reasons exist, and the wrong one sent that reader looking for a question
1648
+ // that was right there.
1649
+ gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness });
1446
1650
  notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
1447
1651
  return { notes };
1448
1652
  }
@@ -1470,6 +1674,8 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
1470
1674
  reply: replies.at(-1),
1471
1675
  agent_acknowledgement_required: response?.agent_acknowledgement_required ??
1472
1676
  entry.agent_acknowledgement_required,
1677
+ agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
1678
+ entry.agent_acknowledgement_text_required,
1473
1679
  };
1474
1680
  });
1475
1681
  const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
@@ -1561,14 +1767,23 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1561
1767
  // its own notification — one ask never stands in for another.
1562
1768
  const submitted = [];
1563
1769
  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');
1770
+ // How long the answer is accepted, and how long this owner listens for it,
1771
+ // are two different spans. They used to be one: the window was whatever
1772
+ // remained of the waiter's ceiling, so a question asked at a turn's end
1773
+ // stopped being answerable about eight minutes later, whatever the user
1774
+ // had configured.
1775
+ //
1776
+ // That was right when the waiter was the only way an answer came back. It
1777
+ // is not any more: a late answer is found by the next turn's poll or
1778
+ // replayed from the journal, so this owner giving up listening says
1779
+ // nothing about whether the user's answer is still wanted.
1780
+ const replyWindowSeconds = ctx.config.reply_window_seconds.value;
1781
+ if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
1782
+ notes.push('too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal');
1570
1783
  continue;
1571
1784
  }
1785
+ // The owner still stops listening at its own ceiling. Only the span the
1786
+ // server accepts answers into changed.
1572
1787
  const ownerDeadlineAt = ceilingAt;
1573
1788
  const questions = pendingQuestions(entry);
1574
1789
  let intent = entry.submission;
@@ -1639,12 +1854,77 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1639
1854
  receipt = await submitQuestion(ctx, intent);
1640
1855
  admissionConfirmed = true;
1641
1856
  }
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.
1857
+ catch (caught) {
1858
+ let err = caught;
1859
+ if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
1860
+ ctx.log?.error('hook.pushed', {
1861
+ ok: false,
1862
+ request_id: intent.request_id,
1863
+ status: err.status,
1864
+ code: err.code,
1865
+ message: err.message,
1866
+ });
1867
+ if (entry.submission !== undefined) {
1868
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
1869
+ clearFrozenSubmission(sessionId, ctx.env, entry);
1870
+ if (entry.body === undefined) {
1871
+ dropPendingQuestion(sessionId, ctx.env, entry);
1872
+ notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it');
1873
+ continue;
1874
+ }
1875
+ const reminted = await prepareQuestionSubmission(ctx, {
1876
+ title: questions[0].text,
1877
+ body: entry.body,
1878
+ questions,
1879
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
1880
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
1881
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
1882
+ event: 'agent_question',
1883
+ windowSeconds: replyWindowSeconds,
1884
+ ownerDeadlineAt,
1885
+ });
1886
+ if ('error' in reminted) {
1887
+ dropPendingQuestion(sessionId, ctx.env, entry);
1888
+ notes.push(reminted.error);
1889
+ continue;
1890
+ }
1891
+ intent = reminted;
1892
+ updateSessionState(sessionId, ctx.env, (current) => {
1893
+ const list = pendingList(current);
1894
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry));
1895
+ if (index < 0)
1896
+ return current;
1897
+ const next = [...list];
1898
+ next[index] = { ...next[index], submission: reminted };
1899
+ return { ...current, pending: next };
1900
+ });
1901
+ try {
1902
+ receipt = await submitQuestion(ctx, intent);
1903
+ admissionConfirmed = true;
1904
+ }
1905
+ catch (retryErr) {
1906
+ if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
1907
+ dropPendingQuestion(sessionId, ctx.env, entry);
1908
+ notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
1909
+ ctx.log?.error('hook.pushed', {
1910
+ ok: false,
1911
+ request_id: intent.request_id,
1912
+ status: retryErr.status,
1913
+ code: retryErr.code,
1914
+ message: retryErr.message,
1915
+ });
1916
+ continue;
1917
+ }
1918
+ err = retryErr;
1919
+ }
1920
+ }
1921
+ else {
1922
+ dropPendingQuestion(sessionId, ctx.env, entry);
1923
+ notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
1924
+ continue;
1925
+ }
1926
+ }
1927
+ if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
1648
1928
  notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
1649
1929
  ctx.log?.error('hook.pushed', {
1650
1930
  ok: false,
@@ -1655,11 +1935,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1655
1935
  });
1656
1936
  continue;
1657
1937
  }
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) });
1938
+ if (!admissionConfirmed) {
1939
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
1940
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
1941
+ }
1663
1942
  }
1664
1943
  if (admissionConfirmed) {
1665
1944
  ctx.log?.info('hook.pushed', {
@@ -1678,13 +1957,21 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1678
1957
  request_id: intent.request_id,
1679
1958
  collapse_key: intent.collapse_key,
1680
1959
  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)
1960
+ // When the server stops accepting an answer — not when this owner stops
1961
+ // waiting for one. Those were the same number until the window became a
1962
+ // setting, and taking the minimum silently capped every question at the
1963
+ // waiter's ceiling: a question asked with a day-long window was swept as
1964
+ // stale about eight minutes later and retired, so the answer given over
1965
+ // lunch met a closed question.
1966
+ //
1967
+ // Both consumers of this field already clamp to the owner's own ceiling
1968
+ // (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
1969
+ // `Math.min(ceilingAt, …)` when deriving the wait), so recording the
1970
+ // real expiry here lengthens what the user gets without lengthening what
1971
+ // this process waits.
1972
+ reply_deadline_at: Number.isFinite(committedReplyDeadline)
1686
1973
  ? committedReplyDeadline
1687
- : intent.owner_deadline_at),
1974
+ : intent.owner_deadline_at,
1688
1975
  };
1689
1976
  delete live.submission;
1690
1977
  submitted.push(live);
@@ -1751,10 +2038,32 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1751
2038
  if (permanentFailure !== null)
1752
2039
  notes.push(permanentFailure);
1753
2040
  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);
2041
+ // This owner is done listening. That used to mean the question was over:
2042
+ // it closed the window server-side and retired the record, on the reasoning
2043
+ // that no later answer could reach the same agent turn.
2044
+ //
2045
+ // The reaching was never the point — the *accepting* is. A question whose
2046
+ // window is still open is polled again by the next prompt and the next
2047
+ // Stop, and an answer found there is journaled and replayed. Closing here
2048
+ // capped every question at this owner's ceiling however long a window the
2049
+ // user had configured, which is what made a day-long window mean eight
2050
+ // minutes.
2051
+ //
2052
+ // So: finalize only what the server has genuinely stopped accepting, and
2053
+ // leave the rest live for the turn that comes next. A session that truly
2054
+ // ends still retires its questions — that is what the session-end hook is
2055
+ // for, and it is the honest place for it.
2056
+ const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
2057
+ entry.reply_deadline_at <= ctx.now() ||
2058
+ // A permanent rejection is the server saying this question can never be
2059
+ // answered, whatever its window says. Leaving it open would strand a
2060
+ // dead question on the user's devices.
2061
+ waited.permanentFailures.has(entry.request_id));
2062
+ const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry));
2063
+ if (stillAnswerable.length > 0) {
2064
+ 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`);
2065
+ }
2066
+ const finalized = await finalizePendings(ctx, expired);
1758
2067
  const finalAnswers = finalized
1759
2068
  .map(finalizedAnswer)
1760
2069
  .filter((entry) => entry !== null);
@@ -1809,6 +2118,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
1809
2118
  reply: authoritative.at(-1),
1810
2119
  replies: authoritative,
1811
2120
  agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
2121
+ agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
1812
2122
  });
1813
2123
  if (finalized.response === null) {
1814
2124
  notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
@@ -1893,12 +2203,21 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
1893
2203
  // ask — register a question for the turn's end
1894
2204
  // ---------------------------------------------------------------------------
1895
2205
  /**
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.
2206
+ * Registrations still waiting for their first push. More than this in one turn
2207
+ * means an agent is looping, not asking, and related questions belong in one
2208
+ * `ask --form` instead.
2209
+ *
2210
+ * Deliberately counts only unasked entries. A question already on the user's
2211
+ * devices is waiting on a person, not on the agent, and an answer stays
2212
+ * accepted for a day — so counting live questions here would have turned a
2213
+ * patient user into a reason the agent could no longer ask anything.
1900
2214
  */
1901
2215
  export const MAX_PENDING_QUESTIONS = 4;
2216
+ /**
2217
+ * Everything this session has open at once, asked or not. The lock screen is
2218
+ * the shared resource being protected here, not the agent's turn.
2219
+ */
2220
+ export const MAX_LIVE_QUESTIONS = 10;
1902
2221
  /**
1903
2222
  * A session may hold several registered questions at once: a new `ask` never
1904
2223
  * ends an earlier one. Each reaches the user as its own notification and is
@@ -1912,11 +2231,16 @@ export const MAX_PENDING_QUESTIONS = 4;
1912
2231
  * running in one project at once, and their questions must never interfere.
1913
2232
  */
1914
2233
  export function registerQuestion(sessionId, env, question, now = Date.now()) {
1915
- let full = false;
2234
+ let full = null;
1916
2235
  updateSessionState(sessionId, env, (state) => {
1917
2236
  const pending = pendingList(state);
1918
- if (pending.length >= MAX_PENDING_QUESTIONS) {
1919
- full = true;
2237
+ const unasked = pending.filter((entry) => entry.request_id === undefined);
2238
+ if (unasked.length >= MAX_PENDING_QUESTIONS) {
2239
+ full = 'unasked';
2240
+ return state;
2241
+ }
2242
+ if (pending.length >= MAX_LIVE_QUESTIONS) {
2243
+ full = 'live';
1920
2244
  return state;
1921
2245
  }
1922
2246
  return {
@@ -1932,10 +2256,14 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
1932
2256
  ],
1933
2257
  };
1934
2258
  });
1935
- if (full) {
2259
+ if (full === 'unasked') {
1936
2260
  throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
1937
2261
  'Combine related questions into one `notifai ask --form` instead of registering more.');
1938
2262
  }
2263
+ if (full === 'live') {
2264
+ throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
2265
+ 'Retire the ones you no longer need with `notifai close <request_id>` before asking another.');
2266
+ }
1939
2267
  }
1940
2268
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
1941
2269
  export function parseHookInput(raw) {