@raidiant/notifai 1.0.1 → 2.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 (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +17 -7
  3. package/dist/client.d.ts +5 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands.d.ts +10 -1
  7. package/dist/commands.js +151 -54
  8. package/dist/commands.js.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.js +5 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/credentials.d.ts +54 -3
  13. package/dist/credentials.js +237 -18
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/file-lock.js +15 -6
  16. package/dist/file-lock.js.map +1 -1
  17. package/dist/hook-adapter.d.ts +6 -2
  18. package/dist/hook-adapter.js +110 -29
  19. package/dist/hook-adapter.js.map +1 -1
  20. package/dist/hooks.d.ts +15 -1
  21. package/dist/hooks.js +183 -12
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +50 -21
  24. package/dist/install-hooks.js +141 -57
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/logging.d.ts +1 -1
  27. package/dist/logging.js +4 -0
  28. package/dist/logging.js.map +1 -1
  29. package/dist/main.js +14 -3
  30. package/dist/main.js.map +1 -1
  31. package/dist/native-skills.js +5 -10
  32. package/dist/native-skills.js.map +1 -1
  33. package/dist/opencode-plugin.d.ts +7 -2
  34. package/dist/opencode-plugin.js +142 -4
  35. package/dist/opencode-plugin.js.map +1 -1
  36. package/dist/platform.d.ts +73 -0
  37. package/dist/platform.js +143 -0
  38. package/dist/platform.js.map +1 -0
  39. package/dist/ui/config-view.js +5 -1
  40. package/dist/ui/config-view.js.map +1 -1
  41. package/package.json +11 -10
  42. package/src/client.test.ts +53 -0
  43. package/src/client.ts +24 -0
  44. package/src/commands.test.ts +351 -30
  45. package/src/commands.ts +207 -49
  46. package/src/config.ts +11 -9
  47. package/src/credentials.test.ts +291 -0
  48. package/src/credentials.ts +274 -17
  49. package/src/file-lock.ts +34 -9
  50. package/src/hook-adapter.test.ts +136 -0
  51. package/src/hook-adapter.ts +120 -28
  52. package/src/hooks.test.ts +175 -1
  53. package/src/hooks.ts +241 -12
  54. package/src/install-hooks.test.ts +174 -33
  55. package/src/install-hooks.ts +186 -54
  56. package/src/logging.ts +4 -0
  57. package/src/main.ts +17 -2
  58. package/src/native-skills.ts +5 -10
  59. package/src/opencode-plugin.ts +148 -3
  60. package/src/platform.test.ts +152 -0
  61. package/src/platform.ts +173 -0
  62. package/src/skill.test.ts +32 -1
  63. package/src/ui/config-view.ts +5 -5
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,6 +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,
121
+ reply_protocol_version: 2,
118
122
  last_seen_at: null,
119
123
  },
120
124
  {
@@ -123,12 +127,36 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
123
127
  platform: 'macos' as const,
124
128
  permission_status: 'authorized',
125
129
  registration_healthy: true,
130
+ reply_protocol_version: 2,
126
131
  last_seen_at: null,
127
132
  },
128
133
  ],
129
134
  }),
130
135
  capabilities: notUsed,
131
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
+ },
132
160
  createMediaUpload: notUsed,
133
161
  uploadMedia: notUsed,
134
162
  health: async () => true,
@@ -143,6 +171,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
143
171
  return {
144
172
  request_id: requestId,
145
173
  reply_expires_at: new Date(NOW + 480_000).toISOString(),
174
+ agent_acknowledgement_required:
175
+ recorder.acknowledgementRequiredFor?.(requestId) ?? true,
146
176
  replayed: false,
147
177
  overall: 'provider_accepted_all',
148
178
  deliveries: [],
@@ -154,6 +184,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
154
184
  return ({
155
185
  request_id: requestId,
156
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,
157
192
  replies: recordedReplies(requestId),
158
193
  }) satisfies ListRepliesResponse
159
194
  },
@@ -162,6 +197,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
162
197
  return {
163
198
  request_id: requestId,
164
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,
165
205
  replies: recordedReplies(requestId),
166
206
  } satisfies ListRepliesResponse
167
207
  },
@@ -716,6 +756,30 @@ describe('terminal-first grace window', () => {
716
756
  ).toBeGreaterThanOrEqual(60)
717
757
  })
718
758
 
759
+ it('never pushes a question to a Companion without the current reply protocol', async () => {
760
+ const h = harness([])
761
+ const factory = h.deps.clientFactory
762
+ h.deps.clientFactory = () => {
763
+ const client = factory!()
764
+ return {
765
+ ...client,
766
+ listDevices: async () => ({
767
+ devices: (await client.listDevices()).devices.map((device) => ({
768
+ ...device,
769
+ reply_protocol_version: null,
770
+ })),
771
+ }),
772
+ } as ApiClient
773
+ }
774
+ writeSessionState('old-companion', h.env, { last_prompt_at: AWAY })
775
+ registerQuestion('old-companion', h.env, { question: 'Ship it?' }, NOW)
776
+
777
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'old-companion' }))
778
+
779
+ expect(h.recorder.submitted).toHaveLength(0)
780
+ expect(h.io.errLines.join('\n')).toContain('no device can answer a question yet')
781
+ })
782
+
719
783
  it('charges transport overrun and close time to the one waiter ceiling', async () => {
720
784
  const h = harness([])
721
785
  const factory = h.deps.clientFactory
@@ -816,6 +880,8 @@ describe('nagging guards', () => {
816
880
  registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
817
881
 
818
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]!)
819
885
  await h.deps.sleep?.(1)
820
886
  registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
821
887
  h.io.outLines = []
@@ -1189,6 +1255,8 @@ describe('the waiter owning one question to the end', () => {
1189
1255
  ])
1190
1256
 
1191
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]!)
1192
1260
  h.io.outLines = []
1193
1261
  await hookRunCommand(
1194
1262
  h.deps,
@@ -1595,6 +1663,8 @@ describe('several questions in flight', () => {
1595
1663
  expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
1596
1664
  expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
1597
1665
 
1666
+ h.recorder.acknowledged ??= new Set()
1667
+ h.recorder.acknowledged.add('req_existing')
1598
1668
  h.io.outLines = []
1599
1669
  await hookRunCommand(
1600
1670
  h.deps,
@@ -1612,6 +1682,104 @@ describe('several questions in flight', () => {
1612
1682
  ).toBe('answered')
1613
1683
  })
1614
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
+
1615
1783
  it('resumes with a partial answer and keeps the rest registered', async () => {
1616
1784
  const h = harness([])
1617
1785
  const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
@@ -1632,6 +1800,8 @@ describe('several questions in flight', () => {
1632
1800
  // The remaining question's answer arrives before the next turn ends; the
1633
1801
  // late-answer path hands it over without asking anything twice.
1634
1802
  byRequest.set('req_hook_2', [reply({ text: 'Staging' })])
1803
+ h.recorder.acknowledged ??= new Set()
1804
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
1635
1805
  h.io.outLines = []
1636
1806
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3', stop_hook_active: true }))
1637
1807
 
@@ -2153,6 +2323,8 @@ describe('user-prompt-submit hook', () => {
2153
2323
  registerQuestion('s16', h.env, { question: 'Ship it?' })
2154
2324
 
2155
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]!)
2156
2328
  await hookRunCommand(
2157
2329
  h.deps,
2158
2330
  'stop',
@@ -2247,7 +2419,7 @@ describe('session-end hook', () => {
2247
2419
  await waitUntil(
2248
2420
  () =>
2249
2421
  readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
2250
- 700,
2422
+ 5_000,
2251
2423
  'the real Codex hook did not reach contended diagnostics before its deadline',
2252
2424
  )
2253
2425
 
@@ -2373,6 +2545,8 @@ describe('telling concurrent agents apart', () => {
2373
2545
  registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2374
2546
 
2375
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]!)
2376
2550
  await hookRunCommand(
2377
2551
  h.deps,
2378
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'
@@ -976,7 +989,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
976
989
  .filter(
977
990
  (device) =>
978
991
  (device.platform === 'ios' || device.platform === 'macos') &&
979
- device.registration_healthy,
992
+ device.registration_healthy &&
993
+ device.reply_protocol_version === 2,
980
994
  )
981
995
  .filter((device) => configured === null || configured.includes(device.device_id))
982
996
  .map((device) => device.device_id)
@@ -1339,6 +1353,8 @@ interface AnsweredPending {
1339
1353
  pending: PendingQuestion
1340
1354
  reply: ReplyView
1341
1355
  replies: ReplyView[]
1356
+ /** Immutable server snapshot for this request, known after a replies/close response. */
1357
+ agent_acknowledgement_required?: boolean | undefined
1342
1358
  }
1343
1359
 
1344
1360
  interface FinalizedPending {
@@ -1363,7 +1379,15 @@ async function finalizePendings(
1363
1379
  function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
1364
1380
  const replies = finalized.response?.replies ?? []
1365
1381
  const reply = replies.at(-1)
1366
- 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
+ }
1367
1391
  }
1368
1392
 
1369
1393
  /** Persist and surface one answer without letting the two representations drift. */
@@ -1475,13 +1499,45 @@ function answerContext(answered: AnsweredPending): string {
1475
1499
  * tied to the question that asked it, with a truthful note about anything
1476
1500
  * still waiting.
1477
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
+
1478
1533
  function answersContext(answered: AnsweredPending[], remaining: number): string {
1479
1534
  const tail =
1480
1535
  remaining > 0
1481
1536
  ? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
1482
1537
  : ''
1538
+ const guidance = acknowledgementContext(answered)
1483
1539
  if (answered.length === 1) {
1484
- return answerContext(answered[0]!) + tail
1540
+ return answerContext(answered[0]!) + tail + guidance
1485
1541
  }
1486
1542
  const lines = answered.map(({ pending, replies }) => {
1487
1543
  const latest = replies.at(-1)!
@@ -1493,7 +1549,7 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
1493
1549
  const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
1494
1550
  return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
1495
1551
  })
1496
- 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}`
1497
1553
  }
1498
1554
 
1499
1555
  function stopAnswerOutput(context: string): string {
@@ -1524,12 +1580,25 @@ function stageAcceptedAnswers(
1524
1580
  const pendingRemaining = pendingList(current).filter(
1525
1581
  (entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
1526
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
+ }
1527
1594
  const next: SessionState = {
1528
1595
  ...current,
1529
1596
  accepted,
1530
1597
  }
1531
1598
  if (pendingRemaining.length > 0) next.pending = pendingRemaining
1532
1599
  else delete next.pending
1600
+ if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
1601
+ else delete next.acknowledgement_due
1533
1602
  return next
1534
1603
  })
1535
1604
  return accepted
@@ -1546,6 +1615,68 @@ function amendAcceptedAnswers(
1546
1615
  )
1547
1616
  }
1548
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
+
1549
1680
  /**
1550
1681
  * Retire the answered questions and close the journal.
1551
1682
  *
@@ -1665,7 +1796,14 @@ export async function handleUserPromptSubmit(
1665
1796
  `could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
1666
1797
  )
1667
1798
  }
1668
- 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
+ }
1669
1807
  })
1670
1808
  if (answered.length > 0) {
1671
1809
  stageAcceptedAnswers(
@@ -1799,6 +1937,27 @@ export async function runEscalationWaiter(
1799
1937
  let state = readSessionState(sessionId, ctx.env)
1800
1938
  if (state.accepted !== undefined) {
1801
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
+ }
1802
1961
  // Acknowledgement is per route, because routes end in different places.
1803
1962
  //
1804
1963
  // A blocking continuation ends at this process's stdout, which the
@@ -1819,6 +1978,54 @@ export async function runEscalationWaiter(
1819
1978
  settleAcceptedAnswers(ctx, sessionId, accepted)
1820
1979
  settledAnswerThisPass = true
1821
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)
1822
2029
  }
1823
2030
 
1824
2031
  const pending = pendingList(state)
@@ -2013,7 +2220,14 @@ async function handleClaimedStop(
2013
2220
  `could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
2014
2221
  )
2015
2222
  }
2016
- 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
+ }
2017
2231
  })
2018
2232
  const accepted = stageAcceptedAnswers(
2019
2233
  ctx,
@@ -2428,7 +2642,13 @@ async function escalate(
2428
2642
  // unreachable, never discard the durable reply already observed.
2429
2643
  const finalReplies = finalized.response?.replies ?? []
2430
2644
  const authoritative = finalReplies.length > 0 ? finalReplies : replies
2431
- 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
+ })
2432
2652
  if (finalized.response === null) {
2433
2653
  notes.push(
2434
2654
  `could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
@@ -2490,19 +2710,28 @@ export function handleSessionEnd(
2490
2710
  `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
2491
2711
  )
2492
2712
  }
2493
- if (state.accepted !== undefined) {
2494
- 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
+ }
2495
2719
  if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
2496
2720
  if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
2497
2721
  if (state.continuation !== undefined) preserved.continuation = state.continuation
2498
2722
  writeSessionState(sessionId, env, preserved)
2499
- 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
+ )
2500
2728
  return {
2501
2729
  notes,
2502
2730
  log: {
2503
- outcome: 'answer-preserved',
2731
+ outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
2504
2732
  queued_retirements: orphans.length,
2505
- accepted_answers: state.accepted.answers.length,
2733
+ accepted_answers: state.accepted?.answers.length ?? 0,
2734
+ acknowledgement_due: state.acknowledgement_due?.length ?? 0,
2506
2735
  },
2507
2736
  }
2508
2737
  }