@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/src/hooks.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  writeFileSync,
12
12
  } from 'node:fs'
13
13
  import path from 'node:path'
14
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
14
15
  import type {
15
16
  LifecycleEndState,
16
17
  ListRepliesResponse,
@@ -127,11 +128,19 @@ export interface SessionState {
127
128
  * the service confirms the agent-authored follow-up exists.
128
129
  */
129
130
  acknowledgement_due?: AcknowledgementDue[]
131
+ /** Consecutive turns this session has been held for an acknowledgement. */
132
+ acknowledgement_blocks?: number
130
133
  }
131
134
 
132
135
  export interface AcknowledgementDue {
133
136
  request_id: string
134
137
  recorded_at: number
138
+ /**
139
+ * Whether this request's acknowledgement must carry text. Absent on state
140
+ * written before the obligation started recording it; treated as true, which
141
+ * is the default the service ships.
142
+ */
143
+ text_required?: boolean
135
144
  }
136
145
 
137
146
  interface AcceptedAnswerDelivery {
@@ -152,6 +161,8 @@ interface AcceptedAnswerDelivery {
152
161
  * passes through.
153
162
  */
154
163
  delivery_attempts?: number
164
+ /** Turns this answer has been held without being handed to the agent. */
165
+ held_deliveries?: number
155
166
  }
156
167
 
157
168
  /** A delivered question awaiting its retirement push. */
@@ -184,12 +195,15 @@ export interface OrphanRetirement extends RetiringQuestion {
184
195
  }
185
196
 
186
197
  /**
187
- * Past this, the question's reply window (3600s) has long expired server-side
188
- * and the companion shows it as dead on next open anyway; pushing a retirement
189
- * sync for it is noise. Also the backstop that keeps an unreachable server
190
- * from growing the queue for ever.
198
+ * A retirement is only worth syncing while the question it retires could still
199
+ * be answered, so this has to outlive the longest reply window a user can
200
+ * choose — it is derived from that bound rather than from a guess about how
201
+ * long questions stay open, which is exactly the assumption that broke when
202
+ * the window stopped being one hour. Past it the companion shows the question
203
+ * as dead on next open anyway. Also the backstop that keeps an unreachable
204
+ * server from growing the queue for ever.
191
205
  */
192
- const ORPHAN_TTL_MS = 24 * 3600 * 1000
206
+ const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000
193
207
 
194
208
  /** More orphans than this means something is looping; keep the newest. */
195
209
  const ORPHAN_QUEUE_CAP = 50
@@ -258,12 +272,35 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
258
272
  */
259
273
  export const WAITER_CEILING_SECONDS = 480
260
274
 
275
+ /**
276
+ * The same wall clock where the waiter runs in the background instead of
277
+ * inside a held turn.
278
+ *
279
+ * On a host that holds its turn, 480 s is a real cost to the person sitting in
280
+ * front of it, and the number above is right. On Claude Code the Stop hook
281
+ * returns at once and the waiter is detached, so the same 480 s buys nothing
282
+ * and costs a direct wake: an answer arriving at minute nine had to wait for
283
+ * the session's next turn even though nothing was blocked.
284
+ *
285
+ * This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
286
+ * because that declaration's kill is silent, and under the reply window the
287
+ * question was pushed with. Past it the journal still delivers at the next
288
+ * turn — the ceiling decides how long a *direct* wake stays possible, never
289
+ * whether the answer survives.
290
+ */
291
+ export const DETACHED_WAITER_CEILING_SECONDS = 3300
292
+
293
+ /** The waiter's wall clock, which depends on whether a turn is held open for it. */
294
+ export function waiterCeilingSeconds(detached: boolean): number {
295
+ return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS
296
+ }
297
+
261
298
  /**
262
299
  * The wire contract's shortest reply window. A question submitted with less
263
300
  * than this is rejected outright, and accepting one would in any case let the
264
301
  * server go on taking an answer after the waiter that owns it has returned.
265
302
  */
266
- const MIN_REPLY_WINDOW_SECONDS = 60
303
+ export const MIN_REPLY_WINDOW_SECONDS = 60
267
304
 
268
305
  /**
269
306
  * The terminal-first wait: the question sits in the terminal for
@@ -476,6 +513,17 @@ export function pruneAbandonedSessions(
476
513
  * no trustworthy PID fall back to an age limit.
477
514
  */
478
515
  const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
516
+
517
+ /**
518
+ * How long a crashed claim *guard* blocks the next hook.
519
+ *
520
+ * The claim itself may legitimately be held for the whole waiter, so it takes
521
+ * that ceiling. The guard is a short lock around the claim's own bookkeeping —
522
+ * a few file operations — and borrowing the waiter's ceiling for it meant a
523
+ * hook killed at exactly the wrong moment wedged this session's Stop path for
524
+ * eight minutes, for a critical section that never runs longer than a second.
525
+ */
526
+ const CLAIM_GUARD_TTL_MS = 30_000
479
527
  const heldClaims = new Map<string, string>()
480
528
 
481
529
  export function claimQuestionPush(
@@ -569,7 +617,7 @@ function acquireClaimGuard(file: string): boolean {
569
617
  } catch (err) {
570
618
  if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
571
619
  try {
572
- if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS) return false
620
+ if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS) return false
573
621
  } catch {
574
622
  // A vanished/corrupt guard is safe to retry once.
575
623
  }
@@ -795,19 +843,29 @@ export interface HookContext {
795
843
  * the user sees in the terminal — but it is free to be rewritten, and a log a
796
844
  * filter cannot rely on is a log nobody filters.
797
845
  */
798
- export type GateReason =
799
- | 'no-session'
800
- | 'no-question'
801
- | 'answered'
802
- | 'already-asked'
803
- | 'continuation-repeat'
804
- | 'continuation-limit'
805
- | 'delivery-limit'
806
- | 'acknowledgement-required'
807
- | 'notifications-off'
808
- | 'claimed-elsewhere'
809
- | 'no-devices'
810
- | 'proceeding'
846
+ export const GATE_REASONS = [
847
+ 'no-session',
848
+ 'no-question',
849
+ 'answered',
850
+ 'continuation-repeat',
851
+ 'continuation-limit',
852
+ 'delivery-limit',
853
+ 'acknowledgement-required',
854
+ 'acknowledgement-abandoned',
855
+ 'harness-cannot-continue',
856
+ 'notifications-off',
857
+ 'claimed-elsewhere',
858
+ 'elapsed',
859
+ 'proceeding',
860
+ ] as const
861
+
862
+ /**
863
+ * A runtime list rather than a bare type, so the guidance that tells agents to
864
+ * filter on these can be tested against the set the code can actually emit.
865
+ * `already-asked` and `no-devices` were declared here for a long time and never
866
+ * emitted once; documenting them taught agents to filter for silence.
867
+ */
868
+ export type GateReason = (typeof GATE_REASONS)[number]
811
869
 
812
870
  function gate(
813
871
  ctx: HookContext,
@@ -875,7 +933,7 @@ async function prepareQuestionSubmission(
875
933
  ...(options.media !== undefined ? { media: options.media } : {}),
876
934
  device: answerable,
877
935
  reply: true,
878
- replyWindow: Math.max(60, options.windowSeconds),
936
+ replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
879
937
  questions: options.questions,
880
938
  collapseKey,
881
939
  },
@@ -1312,6 +1370,21 @@ const LATE_PROMPT_POLL_SECONDS = 3
1312
1370
  */
1313
1371
  export const MAX_CONTINUATION_COUNT = 3
1314
1372
 
1373
+ /**
1374
+ * How many turns an answer may be *held* before it is settled unread.
1375
+ *
1376
+ * A hold is not a delivery: the route reported that it handed nothing over, so
1377
+ * the agent has not seen the answer and the wake-loop the continuation cap
1378
+ * exists to stop has not happened. Counting holds against that cap meant three
1379
+ * turns where a liveness probe could not reach a busy session were enough to
1380
+ * discard an answer the user had already given.
1381
+ *
1382
+ * It still has to end. A route that can never deliver would otherwise retry
1383
+ * every turn for the life of the session, so holds get their own, far looser
1384
+ * bound — loose because each one costs nothing and the answer is still wanted.
1385
+ */
1386
+ export const MAX_HELD_DELIVERIES = 20
1387
+
1315
1388
  interface PendingPoll {
1316
1389
  /** The answer to act on: the latest reply, because a later one corrects. */
1317
1390
  reply: ReplyView | null
@@ -1352,6 +1425,8 @@ interface AnsweredPending {
1352
1425
  replies: ReplyView[]
1353
1426
  /** Immutable server snapshot for this request, known after a replies/close response. */
1354
1427
  agent_acknowledgement_required?: boolean | undefined
1428
+ /** Whether that acknowledgement must carry text; the account's snapshot. */
1429
+ agent_acknowledgement_text_required?: boolean | undefined
1355
1430
  }
1356
1431
 
1357
1432
  interface FinalizedPending {
@@ -1384,6 +1459,8 @@ function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1384
1459
  replies,
1385
1460
  agent_acknowledgement_required:
1386
1461
  finalized.response?.agent_acknowledgement_required,
1462
+ agent_acknowledgement_text_required:
1463
+ finalized.response?.agent_acknowledgement_text_required,
1387
1464
  }
1388
1465
  }
1389
1466
 
@@ -1449,6 +1526,46 @@ function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
1449
1526
  return a.question === b.question && a.asked_at === b.asked_at
1450
1527
  }
1451
1528
 
1529
+ /**
1530
+ * The frozen draft itself will never be accepted. Replaying it forever hides a
1531
+ * contract-skew (deleted fields, unexpected properties) as a recoverable wait.
1532
+ * Auth, conflicts, and timeouts are not this class.
1533
+ */
1534
+ function isTerminalDraftRejection(err: ApiCallError): boolean {
1535
+ return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
1536
+ }
1537
+
1538
+ function dropPendingQuestion(
1539
+ sessionId: string,
1540
+ env: NodeJS.ProcessEnv,
1541
+ entry: PendingQuestion,
1542
+ ): void {
1543
+ updateSessionState(sessionId, env, (current) => {
1544
+ const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
1545
+ const next: SessionState = { ...current }
1546
+ if (pending.length > 0) next.pending = pending
1547
+ else delete next.pending
1548
+ return next
1549
+ })
1550
+ }
1551
+
1552
+ function clearFrozenSubmission(
1553
+ sessionId: string,
1554
+ env: NodeJS.ProcessEnv,
1555
+ entry: PendingQuestion,
1556
+ ): void {
1557
+ updateSessionState(sessionId, env, (current) => {
1558
+ const list = pendingList(current)
1559
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
1560
+ if (index < 0) return current
1561
+ const next = [...list]
1562
+ const copy = { ...next[index]! }
1563
+ delete copy.submission
1564
+ next[index] = copy
1565
+ return { ...current, pending: next }
1566
+ })
1567
+ }
1568
+
1452
1569
  /** The question set this pending record pushes, however it was registered. */
1453
1570
  function pendingQuestions(pending: PendingQuestion): QuestionT[] {
1454
1571
  return pending.questions ?? [{ id: 'q1', text: pending.question }]
@@ -1496,8 +1613,20 @@ function answerContext(answered: AnsweredPending): string {
1496
1613
  * tied to the question that asked it, with a truthful note about anything
1497
1614
  * still waiting.
1498
1615
  */
1499
- function acknowledgementCommand(requestId: string): string {
1500
- return `notifai acknowledge ${requestId} --text <text>`
1616
+ function acknowledgementCommand(requestId: string, textRequired = true): string {
1617
+ return textRequired
1618
+ ? `notifai acknowledge ${requestId} --text <text>`
1619
+ : `notifai acknowledge ${requestId}`
1620
+ }
1621
+
1622
+ /**
1623
+ * The acknowledgement is owed either way; only its text is conditional. So the
1624
+ * instruction never says "you may skip this" — it says what to run.
1625
+ */
1626
+ function acknowledgementDemand(textRequired: boolean): string {
1627
+ return textRequired
1628
+ ? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
1629
+ : ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words'
1501
1630
  }
1502
1631
 
1503
1632
  function acknowledgementContext(answered: AnsweredPending[]): string {
@@ -1509,20 +1638,23 @@ function acknowledgementContext(answered: AnsweredPending[]): string {
1509
1638
  return ' Agent Acknowledgement is not required for the answered request(s).'
1510
1639
  }
1511
1640
  if (due.length === 1) {
1512
- const requestId = due[0]!.pending.request_id!
1641
+ const entry = due[0]!
1642
+ const requestId = entry.pending.request_id!
1643
+ const textRequired = entry.agent_acknowledgement_text_required !== false
1513
1644
  return (
1514
1645
  ` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
1515
- `\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`
1646
+ `\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`
1516
1647
  )
1517
1648
  }
1649
+ const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false)
1518
1650
  const commands = due
1519
1651
  .map((entry) => {
1520
1652
  const requestId = entry.pending.request_id!
1521
- return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
1653
+ return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``
1522
1654
  })
1523
1655
  .join('\n')
1524
1656
  return (
1525
- ` 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` +
1657
+ ` 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` +
1526
1658
  commands
1527
1659
  )
1528
1660
  }
@@ -1585,7 +1717,11 @@ function stageAcceptedAnswers(
1585
1717
  requestId !== undefined &&
1586
1718
  !acknowledgementDue.some((entry) => entry.request_id === requestId)
1587
1719
  ) {
1588
- acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at })
1720
+ acknowledgementDue.push({
1721
+ request_id: requestId,
1722
+ recorded_at: accepted.recorded_at,
1723
+ text_required: answer.agent_acknowledgement_text_required !== false,
1724
+ })
1589
1725
  }
1590
1726
  }
1591
1727
  const next: SessionState = {
@@ -1633,14 +1769,83 @@ export function clearAcknowledgementObligation(
1633
1769
 
1634
1770
  function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
1635
1771
  const commands = due
1636
- .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
1772
+ .map(
1773
+ (entry) =>
1774
+ `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``,
1775
+ )
1637
1776
  .join('\n')
1777
+ const anyTextRequired = due.some((entry) => entry.text_required !== false)
1638
1778
  return (
1639
1779
  `Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
1640
- `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}`
1780
+ `Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`
1641
1781
  )
1642
1782
  }
1643
1783
 
1784
+ /**
1785
+ * How many turns in a row a session may be held waiting for an acknowledgement.
1786
+ *
1787
+ * The gate is the one place hooks deliberately break the fail-open rule, and it
1788
+ * had no bound at all: an agent that could not or would not acknowledge — or a
1789
+ * server that stayed unreachable, since an error here counts as unresolved —
1790
+ * held every turn of that session for ever. Blocking a user's agent
1791
+ * indefinitely does not get them an acknowledgement; it costs them the agent as
1792
+ * well as the acknowledgement.
1793
+ */
1794
+ const MAX_ACKNOWLEDGEMENT_BLOCKS = 3
1795
+
1796
+ /**
1797
+ * Hold the turn for an outstanding acknowledgement, or give up and let it
1798
+ * through once holding has stopped being worth its cost.
1799
+ *
1800
+ * Returns the outcome that blocks the turn, or `null` to carry on.
1801
+ */
1802
+ function holdForAcknowledgement(
1803
+ ctx: HookContext,
1804
+ sessionId: string,
1805
+ due: readonly AcknowledgementDue[],
1806
+ notes: string[],
1807
+ ): HookOutcome | null {
1808
+ if (due.length === 0) return null
1809
+ const requestIds = due.map((entry) => entry.request_id)
1810
+ const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1
1811
+ if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
1812
+ // Drop the obligation with the reason recorded. The answer was already
1813
+ // delivered; what is lost is the agent's receipt for it, and the log is
1814
+ // where that loss stays visible.
1815
+ for (const requestId of requestIds) clearAcknowledgementObligation(sessionId, ctx.env, requestId)
1816
+ resetAcknowledgementBlocks(sessionId, ctx.env)
1817
+ gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
1818
+ request_ids: requestIds,
1819
+ blocks: blocks - 1,
1820
+ limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
1821
+ })
1822
+ notes.push(
1823
+ `no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`,
1824
+ )
1825
+ return null
1826
+ }
1827
+ updateSessionState(sessionId, ctx.env, (current) => ({
1828
+ ...current,
1829
+ acknowledgement_blocks: blocks,
1830
+ }))
1831
+ gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks })
1832
+ return {
1833
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1834
+ notes,
1835
+ log: { stage: 'acknowledgement-required', request_ids: requestIds },
1836
+ }
1837
+ }
1838
+
1839
+ /** A turn that was not held resets the streak; only consecutive holds count. */
1840
+ function resetAcknowledgementBlocks(sessionId: string, env: NodeJS.ProcessEnv): void {
1841
+ updateSessionState(sessionId, env, (current) => {
1842
+ if (current.acknowledgement_blocks === undefined) return current
1843
+ const next = { ...current }
1844
+ delete next.acknowledgement_blocks
1845
+ return next
1846
+ })
1847
+ }
1848
+
1644
1849
  async function reconcileAcknowledgementObligations(
1645
1850
  ctx: HookContext,
1646
1851
  sessionId: string,
@@ -1800,6 +2005,9 @@ export async function handleUserPromptSubmit(
1800
2005
  agent_acknowledgement_required:
1801
2006
  response?.agent_acknowledgement_required ??
1802
2007
  entry.agent_acknowledgement_required,
2008
+ agent_acknowledgement_text_required:
2009
+ response?.agent_acknowledgement_text_required ??
2010
+ entry.agent_acknowledgement_text_required,
1803
2011
  }
1804
2012
  })
1805
2013
  if (answered.length > 0) {
@@ -1838,6 +2046,16 @@ export async function handleUserPromptSubmit(
1838
2046
  ...(unasked.length > 0 ? { pending: unasked } : {}),
1839
2047
  ...(retiring.length > 0 ? { retiring } : {}),
1840
2048
  ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
2049
+ // Carried across a prompt on purpose. This rewrite rebuilds the state
2050
+ // from named fields, so anything omitted is silently erased — and an
2051
+ // erased obligation means the user never learns their reply was read,
2052
+ // which is the one thing acknowledgement exists to guarantee.
2053
+ ...(current.acknowledgement_due === undefined
2054
+ ? {}
2055
+ : { acknowledgement_due: current.acknowledgement_due }),
2056
+ ...(current.acknowledgement_blocks === undefined
2057
+ ? {}
2058
+ : { acknowledgement_blocks: current.acknowledgement_blocks }),
1841
2059
  // The user typed, so whatever chain of answer-driven continuations was
1842
2060
  // running, a human has taken the turn and the consecutive count starts
1843
2061
  // over. Only a real prompt reaches here: the journal branch above returns
@@ -1987,19 +2205,8 @@ export async function runEscalationWaiter(
1987
2205
  currentAcceptedIds.has(entry.request_id),
1988
2206
  ),
1989
2207
  )
1990
- if (due.length > 0) {
1991
- gate(ctx, 'held', 'acknowledgement-required', {
1992
- request_ids: due.map((entry) => entry.request_id),
1993
- })
1994
- return {
1995
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1996
- notes,
1997
- log: {
1998
- stage: 'acknowledgement-required',
1999
- request_ids: due.map((entry) => entry.request_id),
2000
- },
2001
- }
2002
- }
2208
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes)
2209
+ if (held !== null) return held
2003
2210
  state = readSessionState(sessionId, ctx.env)
2004
2211
  }
2005
2212
 
@@ -2009,19 +2216,9 @@ export async function runEscalationWaiter(
2009
2216
  sessionId,
2010
2217
  state.acknowledgement_due!,
2011
2218
  )
2012
- if (due.length > 0) {
2013
- gate(ctx, 'held', 'acknowledgement-required', {
2014
- request_ids: due.map((entry) => entry.request_id),
2015
- })
2016
- return {
2017
- stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
2018
- notes,
2019
- log: {
2020
- stage: 'acknowledgement-required',
2021
- request_ids: due.map((entry) => entry.request_id),
2022
- },
2023
- }
2024
- }
2219
+ const held = holdForAcknowledgement(ctx, sessionId, due, notes)
2220
+ if (held !== null) return held
2221
+ resetAcknowledgementBlocks(sessionId, ctx.env)
2025
2222
  state = readSessionState(sessionId, ctx.env)
2026
2223
  }
2027
2224
 
@@ -2092,6 +2289,20 @@ async function deliverAcceptedAnswers(
2092
2289
  ): Promise<HookOutcome> {
2093
2290
  const { answers: answered, remaining } = accepted
2094
2291
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
2292
+ const held = accepted.held_deliveries ?? 0
2293
+ if (held >= MAX_HELD_DELIVERIES) {
2294
+ gate(ctx, 'held', 'delivery-limit', {
2295
+ route: route.kind,
2296
+ held,
2297
+ limit: MAX_HELD_DELIVERIES,
2298
+ request_ids: requestIds,
2299
+ })
2300
+ settleAcceptedAnswers(ctx, sessionId, accepted)
2301
+ notes.push(
2302
+ `no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
2303
+ )
2304
+ return { notes }
2305
+ }
2095
2306
  const attempt = (accepted.delivery_attempts ?? 0) + 1
2096
2307
  if (attempt > MAX_CONTINUATION_COUNT) {
2097
2308
  gate(ctx, 'held', 'delivery-limit', {
@@ -2142,6 +2353,17 @@ async function deliverAcceptedAnswers(
2142
2353
  delivered_at: ctx.now(),
2143
2354
  delivered_route: deliveredRoute,
2144
2355
  }))
2356
+ } else if (delivered.acknowledgement === 'held') {
2357
+ // Give the delivery attempt back and count the hold instead. The attempt
2358
+ // was counted before the call because one that dies mid-delivery still
2359
+ // happened and a counter written afterwards could be evaded by crashing —
2360
+ // but a route that *returns* `held` proves both that this process survived
2361
+ // and that nothing was handed over.
2362
+ amendAcceptedAnswers(ctx, sessionId, (current) => ({
2363
+ ...current,
2364
+ delivery_attempts: accepted.delivery_attempts ?? 0,
2365
+ held_deliveries: (current.held_deliveries ?? 0) + 1,
2366
+ }))
2145
2367
  }
2146
2368
  ctx.log?.info('hook.answer', {
2147
2369
  answered: true,
@@ -2182,7 +2404,11 @@ async function handleClaimedStop(
2182
2404
  ) {
2183
2405
  for (const entry of live) await closeQuietly(ctx, entry.request_id!)
2184
2406
  await retirePendings(ctx, envelope, sessionId, pending, 'expired')
2185
- gate(ctx, 'held', 'no-question')
2407
+ // Not `no-question`: there was a question, and this says so. Reading the
2408
+ // log to find out why an ask never travelled is the whole reason these
2409
+ // reasons exist, and the wrong one sent that reader looking for a question
2410
+ // that was right there.
2411
+ gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness })
2186
2412
  notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
2187
2413
  return { notes }
2188
2414
  }
@@ -2224,6 +2450,9 @@ async function handleClaimedStop(
2224
2450
  agent_acknowledgement_required:
2225
2451
  response?.agent_acknowledgement_required ??
2226
2452
  entry.agent_acknowledgement_required,
2453
+ agent_acknowledgement_text_required:
2454
+ response?.agent_acknowledgement_text_required ??
2455
+ entry.agent_acknowledgement_text_required,
2227
2456
  }
2228
2457
  })
2229
2458
  const accepted = stageAcceptedAnswers(
@@ -2352,7 +2581,7 @@ async function escalate(
2352
2581
  await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
2353
2582
  ctx.log?.debug('hook.gate', {
2354
2583
  verdict: 'grace',
2355
- reason: 'elapsed',
2584
+ reason: 'elapsed' satisfies GateReason,
2356
2585
  waited_from: oldest,
2357
2586
  grace_seconds: ctx.config.ask_grace_seconds.value,
2358
2587
  })
@@ -2361,16 +2590,25 @@ async function escalate(
2361
2590
  // its own notification — one ask never stands in for another.
2362
2591
  const submitted: PendingQuestion[] = []
2363
2592
  for (const entry of unasked) {
2364
- // The reply window is the question's own: whatever is left of the waiter's
2365
- // ceiling, granted to the server so it stops accepting answers at the same
2366
- // moment this owner stops listening for them.
2367
- const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000)
2368
- if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
2593
+ // How long the answer is accepted, and how long this owner listens for it,
2594
+ // are two different spans. They used to be one: the window was whatever
2595
+ // remained of the waiter's ceiling, so a question asked at a turn's end
2596
+ // stopped being answerable about eight minutes later, whatever the user
2597
+ // had configured.
2598
+ //
2599
+ // That was right when the waiter was the only way an answer came back. It
2600
+ // is not any more: a late answer is found by the next turn's poll or
2601
+ // replayed from the journal, so this owner giving up listening says
2602
+ // nothing about whether the user's answer is still wanted.
2603
+ const replyWindowSeconds = ctx.config.reply_window_seconds.value
2604
+ if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
2369
2605
  notes.push(
2370
- 'too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal',
2606
+ 'too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal',
2371
2607
  )
2372
2608
  continue
2373
2609
  }
2610
+ // The owner still stops listening at its own ceiling. Only the span the
2611
+ // server accepts answers into changed.
2374
2612
  const ownerDeadlineAt = ceilingAt
2375
2613
  const questions = pendingQuestions(entry)
2376
2614
  let intent = entry.submission
@@ -2439,12 +2677,80 @@ async function escalate(
2439
2677
  try {
2440
2678
  receipt = await submitQuestion(ctx, intent)
2441
2679
  admissionConfirmed = true
2442
- } catch (err) {
2443
- if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
2444
- // A concrete client response is an admission rejection, not an
2445
- // ambiguous commit. Keep the frozen intent for a later retry and leave
2446
- // the question in the terminal; polling a request the server says it
2447
- // did not accept only burns the owner window and hides the real fault.
2680
+ } catch (caught) {
2681
+ let err: unknown = caught
2682
+ if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
2683
+ ctx.log?.error('hook.pushed', {
2684
+ ok: false,
2685
+ request_id: intent.request_id,
2686
+ status: err.status,
2687
+ code: err.code,
2688
+ message: err.message,
2689
+ })
2690
+ if (entry.submission !== undefined) {
2691
+ notes.push(
2692
+ `question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`,
2693
+ )
2694
+ clearFrozenSubmission(sessionId, ctx.env, entry)
2695
+ if (entry.body === undefined) {
2696
+ dropPendingQuestion(sessionId, ctx.env, entry)
2697
+ notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it')
2698
+ continue
2699
+ }
2700
+ const reminted = await prepareQuestionSubmission(ctx, {
2701
+ title: questions[0]!.text,
2702
+ body: entry.body,
2703
+ questions,
2704
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
2705
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
2706
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
2707
+ event: 'agent_question',
2708
+ windowSeconds: replyWindowSeconds,
2709
+ ownerDeadlineAt,
2710
+ })
2711
+ if ('error' in reminted) {
2712
+ dropPendingQuestion(sessionId, ctx.env, entry)
2713
+ notes.push(reminted.error)
2714
+ continue
2715
+ }
2716
+ intent = reminted
2717
+ updateSessionState(sessionId, ctx.env, (current) => {
2718
+ const list = pendingList(current)
2719
+ const index = list.findIndex((candidate) => isSamePending(candidate, entry))
2720
+ if (index < 0) return current
2721
+ const next = [...list]
2722
+ next[index] = { ...next[index]!, submission: reminted }
2723
+ return { ...current, pending: next }
2724
+ })
2725
+ try {
2726
+ receipt = await submitQuestion(ctx, intent)
2727
+ admissionConfirmed = true
2728
+ } catch (retryErr) {
2729
+ if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
2730
+ dropPendingQuestion(sessionId, ctx.env, entry)
2731
+ notes.push(
2732
+ `reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`,
2733
+ )
2734
+ ctx.log?.error('hook.pushed', {
2735
+ ok: false,
2736
+ request_id: intent.request_id,
2737
+ status: retryErr.status,
2738
+ code: retryErr.code,
2739
+ message: retryErr.message,
2740
+ })
2741
+ continue
2742
+ }
2743
+ err = retryErr
2744
+ }
2745
+ } else {
2746
+ dropPendingQuestion(sessionId, ctx.env, entry)
2747
+ notes.push(
2748
+ `question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`,
2749
+ )
2750
+ continue
2751
+ }
2752
+ }
2753
+ if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
2448
2754
  notes.push(
2449
2755
  `question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
2450
2756
  )
@@ -2457,11 +2763,10 @@ async function escalate(
2457
2763
  })
2458
2764
  continue
2459
2765
  }
2460
- // This is deliberately not a fail-open return. A transport failure is
2461
- // ambiguous: the server may already have dispatched the card. The
2462
- // client-generated request id keeps polling/finalization possible.
2463
- notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
2464
- ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
2766
+ if (!admissionConfirmed) {
2767
+ notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
2768
+ ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
2769
+ }
2465
2770
  }
2466
2771
  if (admissionConfirmed) {
2467
2772
  ctx.log?.info('hook.pushed', {
@@ -2481,16 +2786,21 @@ async function escalate(
2481
2786
  request_id: intent.request_id,
2482
2787
  collapse_key: intent.collapse_key,
2483
2788
  device_ids: intent.device_ids,
2484
- // Submission and every later poll share one wall-clock owner. The
2485
- // server may start its relative window a little later while the submit
2486
- // response is in flight; closing at this deadline is therefore the
2487
- // authoritative backstop against accepting an answer into a void.
2488
- reply_deadline_at: Math.min(
2489
- intent.owner_deadline_at,
2490
- Number.isFinite(committedReplyDeadline)
2491
- ? committedReplyDeadline
2492
- : intent.owner_deadline_at,
2493
- ),
2789
+ // When the server stops accepting an answer — not when this owner stops
2790
+ // waiting for one. Those were the same number until the window became a
2791
+ // setting, and taking the minimum silently capped every question at the
2792
+ // waiter's ceiling: a question asked with a day-long window was swept as
2793
+ // stale about eight minutes later and retired, so the answer given over
2794
+ // lunch met a closed question.
2795
+ //
2796
+ // Both consumers of this field already clamp to the owner's own ceiling
2797
+ // (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
2798
+ // `Math.min(ceilingAt, …)` when deriving the wait), so recording the
2799
+ // real expiry here lengthens what the user gets without lengthening what
2800
+ // this process waits.
2801
+ reply_deadline_at: Number.isFinite(committedReplyDeadline)
2802
+ ? committedReplyDeadline
2803
+ : intent.owner_deadline_at,
2494
2804
  }
2495
2805
  delete live.submission
2496
2806
  submitted.push(live)
@@ -2578,10 +2888,37 @@ async function escalate(
2578
2888
  if (permanentFailure !== null) notes.push(permanentFailure)
2579
2889
 
2580
2890
  if (waited.byRequest.size === 0) {
2581
- // Once this owner returns, no shipped command-hook harness can guarantee
2582
- // a later answer reaches the same agent turn. Close first, then retire the
2583
- // local records, so the Companion never accepts an answer into a void.
2584
- const finalized = await finalizePendings(ctx, waitingOn)
2891
+ // This owner is done listening. That used to mean the question was over:
2892
+ // it closed the window server-side and retired the record, on the reasoning
2893
+ // that no later answer could reach the same agent turn.
2894
+ //
2895
+ // The reaching was never the point — the *accepting* is. A question whose
2896
+ // window is still open is polled again by the next prompt and the next
2897
+ // Stop, and an answer found there is journaled and replayed. Closing here
2898
+ // capped every question at this owner's ceiling however long a window the
2899
+ // user had configured, which is what made a day-long window mean eight
2900
+ // minutes.
2901
+ //
2902
+ // So: finalize only what the server has genuinely stopped accepting, and
2903
+ // leave the rest live for the turn that comes next. A session that truly
2904
+ // ends still retires its questions — that is what the session-end hook is
2905
+ // for, and it is the honest place for it.
2906
+ const expired = waitingOn.filter(
2907
+ (entry) =>
2908
+ entry.reply_deadline_at === undefined ||
2909
+ entry.reply_deadline_at <= ctx.now() ||
2910
+ // A permanent rejection is the server saying this question can never be
2911
+ // answered, whatever its window says. Leaving it open would strand a
2912
+ // dead question on the user's devices.
2913
+ waited.permanentFailures.has(entry.request_id!),
2914
+ )
2915
+ const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry))
2916
+ if (stillAnswerable.length > 0) {
2917
+ notes.push(
2918
+ `${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`,
2919
+ )
2920
+ }
2921
+ const finalized = await finalizePendings(ctx, expired)
2585
2922
  const finalAnswers = finalized
2586
2923
  .map(finalizedAnswer)
2587
2924
  .filter((entry): entry is AnsweredPending => entry !== null)
@@ -2643,6 +2980,8 @@ async function escalate(
2643
2980
  replies: authoritative,
2644
2981
  agent_acknowledgement_required:
2645
2982
  finalized.response?.agent_acknowledgement_required,
2983
+ agent_acknowledgement_text_required:
2984
+ finalized.response?.agent_acknowledgement_text_required,
2646
2985
  })
2647
2986
  if (finalized.response === null) {
2648
2987
  notes.push(
@@ -2738,13 +3077,23 @@ export function handleSessionEnd(
2738
3077
  // ---------------------------------------------------------------------------
2739
3078
 
2740
3079
  /**
2741
- * More pending questions than this means an agent is looping, not asking.
2742
- * Related questions belong in one `ask --form` (the wire carries up to four
2743
- * questions per notification); four separate pushes is already a lot of lock
2744
- * screen.
3080
+ * Registrations still waiting for their first push. More than this in one turn
3081
+ * means an agent is looping, not asking, and related questions belong in one
3082
+ * `ask --form` instead.
3083
+ *
3084
+ * Deliberately counts only unasked entries. A question already on the user's
3085
+ * devices is waiting on a person, not on the agent, and an answer stays
3086
+ * accepted for a day — so counting live questions here would have turned a
3087
+ * patient user into a reason the agent could no longer ask anything.
2745
3088
  */
2746
3089
  export const MAX_PENDING_QUESTIONS = 4
2747
3090
 
3091
+ /**
3092
+ * Everything this session has open at once, asked or not. The lock screen is
3093
+ * the shared resource being protected here, not the agent's turn.
3094
+ */
3095
+ export const MAX_LIVE_QUESTIONS = 10
3096
+
2748
3097
  /**
2749
3098
  * A session may hold several registered questions at once: a new `ask` never
2750
3099
  * ends an earlier one. Each reaches the user as its own notification and is
@@ -2763,11 +3112,16 @@ export function registerQuestion(
2763
3112
  question: PendingQuestion,
2764
3113
  now: number = Date.now(),
2765
3114
  ): void {
2766
- let full = false
3115
+ let full: 'unasked' | 'live' | null = null
2767
3116
  updateSessionState(sessionId, env, (state) => {
2768
3117
  const pending = pendingList(state)
2769
- if (pending.length >= MAX_PENDING_QUESTIONS) {
2770
- full = true
3118
+ const unasked = pending.filter((entry) => entry.request_id === undefined)
3119
+ if (unasked.length >= MAX_PENDING_QUESTIONS) {
3120
+ full = 'unasked'
3121
+ return state
3122
+ }
3123
+ if (pending.length >= MAX_LIVE_QUESTIONS) {
3124
+ full = 'live'
2771
3125
  return state
2772
3126
  }
2773
3127
  return {
@@ -2783,12 +3137,18 @@ export function registerQuestion(
2783
3137
  ],
2784
3138
  }
2785
3139
  })
2786
- if (full) {
3140
+ if (full === 'unasked') {
2787
3141
  throw new Error(
2788
3142
  `${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
2789
3143
  'Combine related questions into one `notifai ask --form` instead of registering more.',
2790
3144
  )
2791
3145
  }
3146
+ if (full === 'live') {
3147
+ throw new Error(
3148
+ `${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
3149
+ 'Retire the ones you no longer need with `notifai close <request_id>` before asking another.',
3150
+ )
3151
+ }
2792
3152
  }
2793
3153
 
2794
3154
  /** Parses hook JSON from stdin, tolerating an empty or malformed body. */