@raidiant/notifai 1.1.0 → 2.0.1

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.
package/src/hooks.test.ts CHANGED
@@ -89,6 +89,9 @@ interface Recorder {
89
89
  beforeReplies?: () => Promise<void>
90
90
  /** Per-request answer stream for multi-question ownership tests. */
91
91
  repliesFor?: (requestId: string) => ReplyView[]
92
+ acknowledgementRequiredFor?: (requestId: string) => boolean
93
+ acknowledged?: Set<string>
94
+ acknowledgementChecks?: string[]
92
95
  }
93
96
 
94
97
  function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
@@ -115,7 +118,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
115
118
  platform: 'ios' as const,
116
119
  permission_status: 'authorized',
117
120
  registration_healthy: true,
118
- reply_protocol_version: 1,
121
+ reply_protocol_version: 2,
119
122
  last_seen_at: null,
120
123
  },
121
124
  {
@@ -124,13 +127,36 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
124
127
  platform: 'macos' as const,
125
128
  permission_status: 'authorized',
126
129
  registration_healthy: true,
127
- reply_protocol_version: 1,
130
+ reply_protocol_version: 2,
128
131
  last_seen_at: null,
129
132
  },
130
133
  ],
131
134
  }),
132
135
  capabilities: notUsed,
133
136
  evidence: notUsed,
137
+ putAgentAcknowledgement: async (requestId, body) => {
138
+ recorder.acknowledged ??= new Set()
139
+ recorder.acknowledged.add(requestId)
140
+ return {
141
+ status: 'recorded',
142
+ agent_acknowledgement: {
143
+ text: body.text,
144
+ created_at: new Date(NOW).toISOString(),
145
+ },
146
+ }
147
+ },
148
+ agentAcknowledgement: async (requestId) => {
149
+ recorder.acknowledgementChecks ??= []
150
+ recorder.acknowledgementChecks.push(requestId)
151
+ const required = recorder.acknowledgementRequiredFor?.(requestId) ?? true
152
+ return {
153
+ request_id: requestId,
154
+ agent_acknowledgement_required: required,
155
+ agent_acknowledgement: recorder.acknowledged?.has(requestId)
156
+ ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
157
+ : null,
158
+ }
159
+ },
134
160
  createMediaUpload: notUsed,
135
161
  uploadMedia: notUsed,
136
162
  health: async () => true,
@@ -145,6 +171,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
145
171
  return {
146
172
  request_id: requestId,
147
173
  reply_expires_at: new Date(NOW + 480_000).toISOString(),
174
+ agent_acknowledgement_required:
175
+ recorder.acknowledgementRequiredFor?.(requestId) ?? true,
148
176
  replayed: false,
149
177
  overall: 'provider_accepted_all',
150
178
  deliveries: [],
@@ -156,6 +184,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
156
184
  return ({
157
185
  request_id: requestId,
158
186
  reply_expires_at: null,
187
+ agent_acknowledgement_required:
188
+ recorder.acknowledgementRequiredFor?.(requestId) ?? true,
189
+ agent_acknowledgement: recorder.acknowledged?.has(requestId)
190
+ ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
191
+ : null,
159
192
  replies: recordedReplies(requestId),
160
193
  }) satisfies ListRepliesResponse
161
194
  },
@@ -164,6 +197,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
164
197
  return {
165
198
  request_id: requestId,
166
199
  reply_expires_at: new Date(NOW).toISOString(),
200
+ agent_acknowledgement_required:
201
+ recorder.acknowledgementRequiredFor?.(requestId) ?? true,
202
+ agent_acknowledgement: recorder.acknowledged?.has(requestId)
203
+ ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
204
+ : null,
167
205
  replies: recordedReplies(requestId),
168
206
  } satisfies ListRepliesResponse
169
207
  },
@@ -842,6 +880,8 @@ describe('nagging guards', () => {
842
880
  registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
843
881
 
844
882
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3' }))
883
+ h.recorder.acknowledged ??= new Set()
884
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
845
885
  await h.deps.sleep?.(1)
846
886
  registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
847
887
  h.io.outLines = []
@@ -1215,6 +1255,8 @@ describe('the waiter owning one question to the end', () => {
1215
1255
  ])
1216
1256
 
1217
1257
  answers.set('req_hook_2', [reply({ text: 'Second answer' })])
1258
+ h.recorder.acknowledged ??= new Set()
1259
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
1218
1260
  h.io.outLines = []
1219
1261
  await hookRunCommand(
1220
1262
  h.deps,
@@ -1621,6 +1663,8 @@ describe('several questions in flight', () => {
1621
1663
  expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
1622
1664
  expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
1623
1665
 
1666
+ h.recorder.acknowledged ??= new Set()
1667
+ h.recorder.acknowledged.add('req_existing')
1624
1668
  h.io.outLines = []
1625
1669
  await hookRunCommand(
1626
1670
  h.deps,
@@ -1638,6 +1682,104 @@ describe('several questions in flight', () => {
1638
1682
  ).toBe('answered')
1639
1683
  })
1640
1684
 
1685
+ it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
1686
+ const h = harness([reply({ text: 'Ship it' })])
1687
+ writeSessionState('ack-required', h.env, {
1688
+ pending: [
1689
+ {
1690
+ question: 'Ship it?',
1691
+ request_id: 'req_ack_required',
1692
+ collapse_key: 'collapse-ack-required',
1693
+ device_ids: ['dev_iphone'],
1694
+ reply_deadline_at: NOW + 60_000,
1695
+ },
1696
+ ],
1697
+ })
1698
+
1699
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
1700
+ expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
1701
+ { request_id: 'req_ack_required', recorded_at: NOW },
1702
+ ])
1703
+
1704
+ h.io.outLines = []
1705
+ await hookRunCommand(
1706
+ h.deps,
1707
+ 'stop',
1708
+ stdin({ session_id: 'ack-required', stop_hook_active: true }),
1709
+ )
1710
+
1711
+ const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
1712
+ expect(blocked.decision).toBe('block')
1713
+ expect(blocked.reason).toContain('req_ack_required')
1714
+ expect(blocked.reason).toContain(
1715
+ 'notifai acknowledge req_ack_required --text <text>',
1716
+ )
1717
+ expect(blocked.reason).toContain('concrete work')
1718
+ expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
1719
+ })
1720
+
1721
+ it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
1722
+ const h = harness([])
1723
+ writeSessionState('ack-heal', h.env, {
1724
+ acknowledgement_due: [{ request_id: 'req_ack_heal', recorded_at: NOW }],
1725
+ })
1726
+ h.recorder.acknowledged = new Set(['req_ack_heal'])
1727
+
1728
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-heal' }))
1729
+
1730
+ expect(h.recorder.acknowledgementChecks).toEqual(['req_ack_heal'])
1731
+ expect(readSessionState('ack-heal', h.env).acknowledgement_due).toBeUndefined()
1732
+ expect(h.io.outLines).toEqual([])
1733
+ })
1734
+
1735
+ it('records only the requests whose immutable snapshot requires acknowledgement', async () => {
1736
+ const h = harness([])
1737
+ repliesByRequest(
1738
+ h,
1739
+ new Map([
1740
+ ['req_hook_1', [reply({ text: 'Ship it' })]],
1741
+ ['req_hook_2', [reply({ text: 'Staging' })]],
1742
+ ]),
1743
+ )
1744
+ h.recorder.acknowledgementRequiredFor = (requestId) =>
1745
+ h.recorder.aliases.get(requestId) === 1
1746
+ writeSessionState('ack-mixed', h.env, { last_prompt_at: AWAY })
1747
+ registerQuestion('ack-mixed', h.env, { question: 'Ship it?' }, NOW)
1748
+ registerQuestion('ack-mixed', h.env, { question: 'Deploy where?' }, NOW + 1)
1749
+
1750
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-mixed' }))
1751
+
1752
+ const state = readSessionState('ack-mixed', h.env)
1753
+ expect(state.acknowledgement_due?.map((entry) => entry.request_id)).toEqual([
1754
+ h.recorder.receipts[0],
1755
+ ])
1756
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
1757
+ expect(output.reason).toContain(h.recorder.receipts[0]!)
1758
+ expect(output.reason).not.toContain(h.recorder.receipts[1]!)
1759
+ })
1760
+
1761
+ it('lists every required request and command in a multi-answer continuation', async () => {
1762
+ const h = harness([])
1763
+ repliesByRequest(
1764
+ h,
1765
+ new Map([
1766
+ ['req_hook_1', [reply({ text: 'Ship it' })]],
1767
+ ['req_hook_2', [reply({ text: 'Staging' })]],
1768
+ ]),
1769
+ )
1770
+ writeSessionState('ack-multiple', h.env, { last_prompt_at: AWAY })
1771
+ registerQuestion('ack-multiple', h.env, { question: 'Ship it?' }, NOW)
1772
+ registerQuestion('ack-multiple', h.env, { question: 'Deploy where?' }, NOW + 1)
1773
+
1774
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-multiple' }))
1775
+
1776
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
1777
+ for (const requestId of h.recorder.receipts) {
1778
+ expect(output.reason).toContain(requestId)
1779
+ expect(output.reason).toContain(`notifai acknowledge ${requestId} --text <text>`)
1780
+ }
1781
+ })
1782
+
1641
1783
  it('resumes with a partial answer and keeps the rest registered', async () => {
1642
1784
  const h = harness([])
1643
1785
  const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
@@ -1658,6 +1800,8 @@ describe('several questions in flight', () => {
1658
1800
  // The remaining question's answer arrives before the next turn ends; the
1659
1801
  // late-answer path hands it over without asking anything twice.
1660
1802
  byRequest.set('req_hook_2', [reply({ text: 'Staging' })])
1803
+ h.recorder.acknowledged ??= new Set()
1804
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
1661
1805
  h.io.outLines = []
1662
1806
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3', stop_hook_active: true }))
1663
1807
 
@@ -2179,6 +2323,8 @@ describe('user-prompt-submit hook', () => {
2179
2323
  registerQuestion('s16', h.env, { question: 'Ship it?' })
2180
2324
 
2181
2325
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
2326
+ h.recorder.acknowledged ??= new Set()
2327
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
2182
2328
  await hookRunCommand(
2183
2329
  h.deps,
2184
2330
  'stop',
@@ -2399,6 +2545,8 @@ describe('telling concurrent agents apart', () => {
2399
2545
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2400
2546
 
2401
2547
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2548
+ h.recorder.acknowledged ??= new Set()
2549
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
2402
2550
  await hookRunCommand(
2403
2551
  h.deps,
2404
2552
  'stop',
package/src/hooks.ts CHANGED
@@ -118,6 +118,18 @@ export interface SessionState {
118
118
  * reaches the harness replays it instead of erasing it.
119
119
  */
120
120
  accepted?: AcceptedAnswerDelivery
121
+ /**
122
+ * Required Agent Acknowledgements that the resumed agent still owes. This is
123
+ * separate from answer delivery: the answer journal may settle as soon as a
124
+ * harness accepts the continuation, while this obligation must survive until
125
+ * the service confirms the agent-authored follow-up exists.
126
+ */
127
+ acknowledgement_due?: AcknowledgementDue[]
128
+ }
129
+
130
+ export interface AcknowledgementDue {
131
+ request_id: string
132
+ recorded_at: number
121
133
  }
122
134
 
123
135
  interface AcceptedAnswerDelivery {
@@ -784,6 +796,7 @@ export type GateReason =
784
796
  | 'continuation-repeat'
785
797
  | 'continuation-limit'
786
798
  | 'delivery-limit'
799
+ | 'acknowledgement-required'
787
800
  | 'notifications-off'
788
801
  | 'claimed-elsewhere'
789
802
  | 'no-devices'
@@ -977,7 +990,7 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
977
990
  (device) =>
978
991
  (device.platform === 'ios' || device.platform === 'macos') &&
979
992
  device.registration_healthy &&
980
- device.reply_protocol_version === 1,
993
+ device.reply_protocol_version === 2,
981
994
  )
982
995
  .filter((device) => configured === null || configured.includes(device.device_id))
983
996
  .map((device) => device.device_id)
@@ -1340,6 +1353,8 @@ interface AnsweredPending {
1340
1353
  pending: PendingQuestion
1341
1354
  reply: ReplyView
1342
1355
  replies: ReplyView[]
1356
+ /** Immutable server snapshot for this request, known after a replies/close response. */
1357
+ agent_acknowledgement_required?: boolean | undefined
1343
1358
  }
1344
1359
 
1345
1360
  interface FinalizedPending {
@@ -1364,7 +1379,15 @@ async function finalizePendings(
1364
1379
  function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1365
1380
  const replies = finalized.response?.replies ?? []
1366
1381
  const reply = replies.at(-1)
1367
- return reply === undefined ? null : { pending: finalized.pending, reply, replies }
1382
+ return reply === undefined
1383
+ ? null
1384
+ : {
1385
+ pending: finalized.pending,
1386
+ reply,
1387
+ replies,
1388
+ agent_acknowledgement_required:
1389
+ finalized.response?.agent_acknowledgement_required,
1390
+ }
1368
1391
  }
1369
1392
 
1370
1393
  /** Persist and surface one answer without letting the two representations drift. */
@@ -1476,13 +1499,45 @@ function answerContext(answered: AnsweredPending): string {
1476
1499
  * tied to the question that asked it, with a truthful note about anything
1477
1500
  * still waiting.
1478
1501
  */
1502
+ function acknowledgementCommand(requestId: string): string {
1503
+ return `notifai acknowledge ${requestId} --text <text>`
1504
+ }
1505
+
1506
+ function acknowledgementContext(answered: AnsweredPending[]): string {
1507
+ const due = answered.filter(
1508
+ (entry) =>
1509
+ entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined,
1510
+ )
1511
+ if (due.length === 0) {
1512
+ return ' Agent Acknowledgement is not required for the answered request(s).'
1513
+ }
1514
+ if (due.length === 1) {
1515
+ const requestId = due[0]!.pending.request_id!
1516
+ return (
1517
+ ` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
1518
+ `\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`
1519
+ )
1520
+ }
1521
+ const commands = due
1522
+ .map((entry) => {
1523
+ const requestId = entry.pending.request_id!
1524
+ return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
1525
+ })
1526
+ .join('\n')
1527
+ return (
1528
+ ` 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` +
1529
+ commands
1530
+ )
1531
+ }
1532
+
1479
1533
  function answersContext(answered: AnsweredPending[], remaining: number): string {
1480
1534
  const tail =
1481
1535
  remaining > 0
1482
1536
  ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1483
1537
  : ''
1538
+ const guidance = acknowledgementContext(answered)
1484
1539
  if (answered.length === 1) {
1485
- return answerContext(answered[0]!) + tail
1540
+ return answerContext(answered[0]!) + tail + guidance
1486
1541
  }
1487
1542
  const lines = answered.map(({ pending, replies }) => {
1488
1543
  const latest = replies.at(-1)!
@@ -1494,7 +1549,7 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
1494
1549
  const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
1495
1550
  return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
1496
1551
  })
1497
- return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`
1552
+ return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`
1498
1553
  }
1499
1554
 
1500
1555
  function stopAnswerOutput(context: string): string {
@@ -1525,12 +1580,25 @@ function stageAcceptedAnswers(
1525
1580
  const pendingRemaining = pendingList(current).filter(
1526
1581
  (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1527
1582
  )
1583
+ const acknowledgementDue = [...(current.acknowledgement_due ?? [])]
1584
+ for (const answer of answered) {
1585
+ const requestId = answer.pending.request_id
1586
+ if (
1587
+ answer.agent_acknowledgement_required === true &&
1588
+ requestId !== undefined &&
1589
+ !acknowledgementDue.some((entry) => entry.request_id === requestId)
1590
+ ) {
1591
+ acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at })
1592
+ }
1593
+ }
1528
1594
  const next: SessionState = {
1529
1595
  ...current,
1530
1596
  accepted,
1531
1597
  }
1532
1598
  if (pendingRemaining.length > 0) next.pending = pendingRemaining
1533
1599
  else delete next.pending
1600
+ if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
1601
+ else delete next.acknowledgement_due
1534
1602
  return next
1535
1603
  })
1536
1604
  return accepted
@@ -1547,6 +1615,68 @@ function amendAcceptedAnswers(
1547
1615
  )
1548
1616
  }
1549
1617
 
1618
+ export function clearAcknowledgementObligation(
1619
+ sessionId: string,
1620
+ env: NodeJS.ProcessEnv,
1621
+ requestId: string,
1622
+ ): boolean {
1623
+ let cleared = false
1624
+ updateSessionState(sessionId, env, (current) => {
1625
+ const due = current.acknowledgement_due ?? []
1626
+ const remaining = due.filter((entry) => entry.request_id !== requestId)
1627
+ cleared = remaining.length !== due.length
1628
+ if (!cleared) return current
1629
+ const next = { ...current }
1630
+ if (remaining.length > 0) next.acknowledgement_due = remaining
1631
+ else delete next.acknowledgement_due
1632
+ return next
1633
+ })
1634
+ return cleared
1635
+ }
1636
+
1637
+ function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
1638
+ const commands = due
1639
+ .map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
1640
+ .join('\n')
1641
+ return (
1642
+ `Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
1643
+ `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}`
1644
+ )
1645
+ }
1646
+
1647
+ async function reconcileAcknowledgementObligations(
1648
+ ctx: HookContext,
1649
+ sessionId: string,
1650
+ due: readonly AcknowledgementDue[],
1651
+ ): Promise<AcknowledgementDue[]> {
1652
+ const unresolved: AcknowledgementDue[] = []
1653
+ for (const obligation of due) {
1654
+ try {
1655
+ const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
1656
+ waitSeconds: 0,
1657
+ })
1658
+ if (
1659
+ snapshot.agent_acknowledgement_required === false ||
1660
+ snapshot.agent_acknowledgement !== null
1661
+ ) {
1662
+ clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id)
1663
+ } else {
1664
+ unresolved.push(obligation)
1665
+ }
1666
+ } catch (err) {
1667
+ unresolved.push(obligation)
1668
+ ctx.log?.error('hook.gate', {
1669
+ verdict: 'held',
1670
+ reason: 'acknowledgement-required',
1671
+ stage: 'reconcile-failed',
1672
+ request_id: obligation.request_id,
1673
+ message: err instanceof Error ? err.message : String(err),
1674
+ })
1675
+ }
1676
+ }
1677
+ return unresolved
1678
+ }
1679
+
1550
1680
  /**
1551
1681
  * Retire the answered questions and close the journal.
1552
1682
  *
@@ -1666,7 +1796,14 @@ export async function handleUserPromptSubmit(
1666
1796
  `could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
1667
1797
  )
1668
1798
  }
1669
- return { ...entry, replies, reply: replies.at(-1)! }
1799
+ return {
1800
+ ...entry,
1801
+ replies,
1802
+ reply: replies.at(-1)!,
1803
+ agent_acknowledgement_required:
1804
+ response?.agent_acknowledgement_required ??
1805
+ entry.agent_acknowledgement_required,
1806
+ }
1670
1807
  })
1671
1808
  if (answered.length > 0) {
1672
1809
  stageAcceptedAnswers(
@@ -1800,6 +1937,27 @@ export async function runEscalationWaiter(
1800
1937
  let state = readSessionState(sessionId, ctx.env)
1801
1938
  if (state.accepted !== undefined) {
1802
1939
  const accepted = state.accepted
1940
+ if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
1941
+ const due = await reconcileAcknowledgementObligations(
1942
+ ctx,
1943
+ sessionId,
1944
+ state.acknowledgement_due ?? [],
1945
+ )
1946
+ if (due.length > 0) {
1947
+ gate(ctx, 'held', 'acknowledgement-required', {
1948
+ request_ids: due.map((entry) => entry.request_id),
1949
+ })
1950
+ return {
1951
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1952
+ notes,
1953
+ log: {
1954
+ stage: 'acknowledgement-required',
1955
+ request_ids: due.map((entry) => entry.request_id),
1956
+ },
1957
+ }
1958
+ }
1959
+ state = readSessionState(sessionId, ctx.env)
1960
+ }
1803
1961
  // Acknowledgement is per route, because routes end in different places.
1804
1962
  //
1805
1963
  // A blocking continuation ends at this process's stdout, which the
@@ -1820,6 +1978,54 @@ export async function runEscalationWaiter(
1820
1978
  settleAcceptedAnswers(ctx, sessionId, accepted)
1821
1979
  settledAnswerThisPass = true
1822
1980
  state = readSessionState(sessionId, ctx.env)
1981
+ const currentAcceptedIds = new Set(
1982
+ accepted.answers.flatMap(({ pending }) =>
1983
+ pending.request_id === undefined ? [] : [pending.request_id],
1984
+ ),
1985
+ )
1986
+ const due = await reconcileAcknowledgementObligations(
1987
+ ctx,
1988
+ sessionId,
1989
+ (state.acknowledgement_due ?? []).filter((entry) =>
1990
+ currentAcceptedIds.has(entry.request_id),
1991
+ ),
1992
+ )
1993
+ if (due.length > 0) {
1994
+ gate(ctx, 'held', 'acknowledgement-required', {
1995
+ request_ids: due.map((entry) => entry.request_id),
1996
+ })
1997
+ return {
1998
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
1999
+ notes,
2000
+ log: {
2001
+ stage: 'acknowledgement-required',
2002
+ request_ids: due.map((entry) => entry.request_id),
2003
+ },
2004
+ }
2005
+ }
2006
+ state = readSessionState(sessionId, ctx.env)
2007
+ }
2008
+
2009
+ if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
2010
+ const due = await reconcileAcknowledgementObligations(
2011
+ ctx,
2012
+ sessionId,
2013
+ state.acknowledgement_due!,
2014
+ )
2015
+ if (due.length > 0) {
2016
+ gate(ctx, 'held', 'acknowledgement-required', {
2017
+ request_ids: due.map((entry) => entry.request_id),
2018
+ })
2019
+ return {
2020
+ stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
2021
+ notes,
2022
+ log: {
2023
+ stage: 'acknowledgement-required',
2024
+ request_ids: due.map((entry) => entry.request_id),
2025
+ },
2026
+ }
2027
+ }
2028
+ state = readSessionState(sessionId, ctx.env)
1823
2029
  }
1824
2030
 
1825
2031
  const pending = pendingList(state)
@@ -2014,7 +2220,14 @@ async function handleClaimedStop(
2014
2220
  `could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
2015
2221
  )
2016
2222
  }
2017
- return { ...entry, replies, reply: replies.at(-1)! }
2223
+ return {
2224
+ ...entry,
2225
+ replies,
2226
+ reply: replies.at(-1)!,
2227
+ agent_acknowledgement_required:
2228
+ response?.agent_acknowledgement_required ??
2229
+ entry.agent_acknowledgement_required,
2230
+ }
2018
2231
  })
2019
2232
  const accepted = stageAcceptedAnswers(
2020
2233
  ctx,
@@ -2429,7 +2642,13 @@ async function escalate(
2429
2642
  // unreachable, never discard the durable reply already observed.
2430
2643
  const finalReplies = finalized.response?.replies ?? []
2431
2644
  const authoritative = finalReplies.length > 0 ? finalReplies : replies
2432
- answered.push({ pending: entry, reply: authoritative.at(-1)!, replies: authoritative })
2645
+ answered.push({
2646
+ pending: entry,
2647
+ reply: authoritative.at(-1)!,
2648
+ replies: authoritative,
2649
+ agent_acknowledgement_required:
2650
+ finalized.response?.agent_acknowledgement_required,
2651
+ })
2433
2652
  if (finalized.response === null) {
2434
2653
  notes.push(
2435
2654
  `could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
@@ -2491,19 +2710,28 @@ export function handleSessionEnd(
2491
2710
  `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
2492
2711
  )
2493
2712
  }
2494
- if (state.accepted !== undefined) {
2495
- const preserved: SessionState = { accepted: state.accepted }
2713
+ if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
2714
+ const preserved: SessionState = {}
2715
+ if (state.accepted !== undefined) preserved.accepted = state.accepted
2716
+ if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
2717
+ preserved.acknowledgement_due = state.acknowledgement_due
2718
+ }
2496
2719
  if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
2497
2720
  if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
2498
2721
  if (state.continuation !== undefined) preserved.continuation = state.continuation
2499
2722
  writeSessionState(sessionId, env, preserved)
2500
- notes.push('preserved an accepted device answer for this exact session to resume')
2723
+ notes.push(
2724
+ state.accepted !== undefined
2725
+ ? 'preserved an accepted device answer for this exact session to resume'
2726
+ : 'preserved required Agent Acknowledgement obligations for this exact session',
2727
+ )
2501
2728
  return {
2502
2729
  notes,
2503
2730
  log: {
2504
- outcome: 'answer-preserved',
2731
+ outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
2505
2732
  queued_retirements: orphans.length,
2506
- accepted_answers: state.accepted.answers.length,
2733
+ accepted_answers: state.accepted?.answers.length ?? 0,
2734
+ acknowledgement_due: state.acknowledgement_due?.length ?? 0,
2507
2735
  },
2508
2736
  }
2509
2737
  }
package/src/logging.ts CHANGED
@@ -87,6 +87,10 @@ export const LOG_EVENTS = [
87
87
  'ask.registered',
88
88
  /** An answer came back from a device. */
89
89
  'reply.received',
90
+ /** An agent attempted to acknowledge the user's reply. */
91
+ 'acknowledgement.attempted',
92
+ /** The service recorded or replayed an Agent Acknowledgement. */
93
+ 'acknowledgement.outcome',
90
94
  /** A harness hook started. */
91
95
  'hook.start',
92
96
  /** A hook decided whether a question may leave the terminal, and why. */
package/src/main.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { Command } from 'commander'
4
4
  import {
5
+ acknowledgeCommand,
5
6
  askCommand,
6
7
  accessStatusCommand,
7
8
  authStatusCommand,
@@ -352,6 +353,19 @@ program
352
353
  process.exit(await repliesCommand(deps, requestId, opts))
353
354
  })
354
355
 
356
+ program
357
+ .command('acknowledge <request_id>')
358
+ .helpGroup(GROUP.agent)
359
+ .summary("Tell the user what you'll do because of their reply")
360
+ .description(
361
+ 'Record the required Agent Acknowledgement for a replied-to notification request; never prompts',
362
+ )
363
+ .requiredOption('--text <text>', 'concrete work you will do because of the reply')
364
+ .option('--json', 'machine-readable output')
365
+ .action(async (requestId: string, opts: { text: string; json?: boolean }) => {
366
+ process.exit(await acknowledgeCommand(deps, requestId, opts))
367
+ })
368
+
355
369
  program
356
370
  .command('status <request_id>')
357
371
  .helpGroup(GROUP.agent)
@@ -423,8 +437,9 @@ program
423
437
  .helpGroup(GROUP.agent)
424
438
  .summary('Retire a question so late answers are rejected')
425
439
  .description('Retire a question so late answers are rejected rather than lost')
426
- .action(async (requestId: string) => {
427
- process.exit(await closeCommand(deps, requestId))
440
+ .option('--json', 'machine-readable output')
441
+ .action(async (requestId: string, opts: { json?: boolean }) => {
442
+ process.exit(await closeCommand(deps, requestId, opts))
428
443
  })
429
444
 
430
445
  // Hidden, not removed. It is an entry point the hook installer writes into a