@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.
@@ -80,6 +80,59 @@ describe('a server that never answers', () => {
80
80
  expect(result.replies).toEqual([])
81
81
  })
82
82
 
83
+ it('puts and fetches Agent Acknowledgements on the encoded request path', async () => {
84
+ const seen: { method?: string; url?: string; body?: unknown }[] = []
85
+ const baseUrl = await serving((request, response) => {
86
+ let raw = ''
87
+ request.on('data', (chunk) => {
88
+ raw += String(chunk)
89
+ })
90
+ request.on('end', () => {
91
+ seen.push({
92
+ method: request.method,
93
+ url: request.url,
94
+ ...(raw === '' ? {} : { body: JSON.parse(raw) as unknown }),
95
+ })
96
+ response.setHeader('content-type', 'application/json')
97
+ if (request.method === 'PUT') {
98
+ response.end(
99
+ JSON.stringify({
100
+ status: 'recorded',
101
+ agent_acknowledgement: {
102
+ text: 'I will deploy.',
103
+ created_at: '2026-08-13T12:01:00.000Z',
104
+ },
105
+ }),
106
+ )
107
+ } else {
108
+ response.end(
109
+ JSON.stringify({
110
+ request_id: 'req/encoded',
111
+ agent_acknowledgement_required: true,
112
+ agent_acknowledgement: null,
113
+ }),
114
+ )
115
+ }
116
+ })
117
+ })
118
+ const client = createClient(baseUrl, 'Bearer test')
119
+
120
+ await client.putAgentAcknowledgement('req/encoded', { text: 'I will deploy.' })
121
+ await client.agentAcknowledgement('req/encoded', { waitSeconds: 25 })
122
+
123
+ expect(seen).toEqual([
124
+ {
125
+ method: 'PUT',
126
+ url: '/api/v1/notifications/req%2Fencoded/agent-acknowledgement',
127
+ body: { text: 'I will deploy.' },
128
+ },
129
+ {
130
+ method: 'GET',
131
+ url: '/api/v1/notifications/req%2Fencoded/agent-acknowledgement?wait_seconds=25',
132
+ },
133
+ ])
134
+ })
135
+
83
136
  it('treats a truncated body as a transport failure, not a crash', async () => {
84
137
  // A body that stops mid-JSON used to escape as a raw parse error outside
85
138
  // the retry path, so nothing backed off and retried it.
package/src/client.ts CHANGED
@@ -6,10 +6,13 @@ import type {
6
6
  CreateMediaUploadRequestT,
7
7
  CreateMediaUploadResponse,
8
8
  EvidenceSnapshot,
9
+ GetAgentAcknowledgementResponse,
9
10
  ListDevicesResponse,
10
11
  ListRepliesResponse,
11
12
  Platform,
12
13
  PollPairingResponse,
14
+ PutAgentAcknowledgementRequestT,
15
+ PutAgentAcknowledgementResponse,
13
16
  SubmissionReceipt,
14
17
  SubmitNotificationRequestT,
15
18
  } from '@raidiant/notifai-protocol'
@@ -72,6 +75,14 @@ export interface ApiClient {
72
75
  ): Promise<ListRepliesResponse>
73
76
  /** Retire a question and return the replies committed before the close fence. */
74
77
  closeReplies(requestId: string): Promise<ListRepliesResponse>
78
+ putAgentAcknowledgement(
79
+ requestId: string,
80
+ body: PutAgentAcknowledgementRequestT,
81
+ ): Promise<PutAgentAcknowledgementResponse>
82
+ agentAcknowledgement(
83
+ requestId: string,
84
+ options: { waitSeconds: number },
85
+ ): Promise<GetAgentAcknowledgementResponse>
75
86
  createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
76
87
  uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
77
88
  health(): Promise<boolean>
@@ -223,6 +234,19 @@ export function createClient(
223
234
  'POST',
224
235
  `/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`,
225
236
  ),
237
+ putAgentAcknowledgement: (requestId, body) =>
238
+ call<PutAgentAcknowledgementResponse>(
239
+ 'PUT',
240
+ `/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement`,
241
+ body,
242
+ ),
243
+ agentAcknowledgement: (requestId, { waitSeconds }) =>
244
+ call<GetAgentAcknowledgementResponse>(
245
+ 'GET',
246
+ `/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement?wait_seconds=${waitSeconds}`,
247
+ undefined,
248
+ waitSeconds,
249
+ ),
226
250
  createMediaUpload: (body) => call('POST', '/api/v1/media', body),
227
251
  uploadMedia: async (grant, bytes) => {
228
252
  let response: Response
@@ -23,11 +23,13 @@ import { afterEach, describe, expect, it } from 'vitest'
23
23
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
24
24
  import type { ClaudeWakeAdapters } from './claude-wake.js'
25
25
  import {
26
+ acknowledgeCommand,
26
27
  askCommand,
27
28
  accessStatusCommand,
28
29
  buildQuestions,
29
30
  assessReadiness,
30
31
  capabilitiesCommand,
32
+ closeCommand,
31
33
  configExplainCommand,
32
34
  configSetCommand,
33
35
  configShowCommand,
@@ -257,6 +259,8 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
257
259
 
258
260
  const receipt: SubmissionReceipt = {
259
261
  request_id: 'req_reply_test',
262
+ reply_expires_at: '2026-08-02T18:00:00.000Z',
263
+ agent_acknowledgement_required: true,
260
264
  replayed: false,
261
265
  overall: 'provider_accepted_all',
262
266
  deliveries: [
@@ -285,10 +289,18 @@ const reply: ReplyView = {
285
289
  created_at: '2026-08-01T18:01:00.000Z',
286
290
  }
287
291
 
288
- function replyResponse(replies: ReplyView[] = []): ListRepliesResponse {
292
+ function replyResponse(
293
+ replies: ReplyView[] = [],
294
+ options: {
295
+ required?: boolean
296
+ acknowledgement?: ListRepliesResponse['agent_acknowledgement']
297
+ } = {},
298
+ ): ListRepliesResponse {
289
299
  return {
290
300
  request_id: receipt.request_id,
291
301
  reply_expires_at: '2026-08-02T18:00:00.000Z',
302
+ agent_acknowledgement_required: options.required ?? true,
303
+ agent_acknowledgement: options.acknowledgement ?? null,
292
304
  replies,
293
305
  }
294
306
  }
@@ -635,7 +647,11 @@ describe('command contracts', () => {
635
647
  ).toBe(EXIT.ok)
636
648
  expect(polls).toHaveLength(3)
637
649
  expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
638
- expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
650
+ expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
651
+ expect(io.outLines).toContain('Agent Acknowledgement required.')
652
+ expect(io.outLines.join('\n')).toContain(
653
+ `notifai acknowledge ${receipt.request_id} --text <text>`,
654
+ )
639
655
  const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
640
656
  expect(received).toHaveLength(1)
641
657
  expect(received[0]?.data).toMatchObject({
@@ -718,7 +734,11 @@ describe('command contracts', () => {
718
734
  }),
719
735
  ).toBe(EXIT.ok)
720
736
  expect(replyCalls).toBe(3)
721
- expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
737
+ expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
738
+ expect(io.outLines).toContain('Agent Acknowledgement required.')
739
+ expect(io.outLines.join('\n')).toContain(
740
+ `notifai acknowledge ${receipt.request_id} --text <text>`,
741
+ )
722
742
  expect(io.errLines.join('\n')).not.toContain('internal_error')
723
743
  })
724
744
 
@@ -799,9 +819,14 @@ describe('command contracts', () => {
799
819
  expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
800
820
  type: 'reply_result',
801
821
  request_id: receipt.request_id,
822
+ reply_expires_at: '2026-08-02T18:00:00.000Z',
802
823
  replies: [],
824
+ agent_acknowledgement_required: true,
825
+ agent_acknowledgement: null,
826
+ acknowledgement_command: null,
803
827
  degraded: false,
804
828
  })
829
+ expect(io.outLines).not.toContain('Agent Acknowledgement required.')
805
830
  expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
806
831
  expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
807
832
  })
@@ -827,11 +852,227 @@ describe('command contracts', () => {
827
852
  expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
828
853
  type: 'reply_result',
829
854
  request_id: receipt.request_id,
855
+ reply_expires_at: '2026-08-02T18:00:00.000Z',
830
856
  replies: [reply],
857
+ agent_acknowledgement_required: true,
858
+ agent_acknowledgement: null,
859
+ acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
831
860
  degraded: false,
832
861
  })
833
862
  })
834
863
 
864
+ it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
865
+ const io = new CapturedIo()
866
+ let submitted: { requestId: string; text: string } | undefined
867
+ const client = {
868
+ putAgentAcknowledgement: async (requestId: string, body: { text: string }) => {
869
+ submitted = { requestId, text: body.text }
870
+ return {
871
+ status: 'recorded' as const,
872
+ agent_acknowledgement: {
873
+ text: body.text,
874
+ created_at: '2026-08-13T12:01:00.000Z',
875
+ },
876
+ }
877
+ },
878
+ } as unknown as ApiClient
879
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-acknowledge-log-'))
880
+ const deps = makeDeps(io, client)
881
+ deps.env = {
882
+ XDG_CONFIG_HOME: path.join(root, 'config'),
883
+ XDG_STATE_HOME: path.join(root, 'state'),
884
+ }
885
+ deps.logger = createLogger({ env: deps.env, cmd: 'acknowledge' })
886
+
887
+ expect(
888
+ await acknowledgeCommand(deps, receipt.request_id, {
889
+ text: ' I will deploy staging now. ',
890
+ json: true,
891
+ }),
892
+ ).toBe(EXIT.ok)
893
+ expect(submitted).toEqual({
894
+ requestId: receipt.request_id,
895
+ text: 'I will deploy staging now.',
896
+ })
897
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
898
+ request_id: receipt.request_id,
899
+ outcome: 'recorded',
900
+ acknowledgement: {
901
+ text: 'I will deploy staging now.',
902
+ created_at: '2026-08-13T12:01:00.000Z',
903
+ },
904
+ agent_acknowledgement_required: true,
905
+ })
906
+ const events = readLogRecords(deps.env, {
907
+ request: receipt.request_id,
908
+ event: ['acknowledgement.attempted', 'acknowledgement.outcome'],
909
+ }).records
910
+ expect(events.map((event) => event.event)).toEqual([
911
+ 'acknowledgement.attempted',
912
+ 'acknowledgement.outcome',
913
+ ])
914
+ })
915
+
916
+ it('clears a matching active-session obligation after acknowledgement success', async () => {
917
+ const io = new CapturedIo()
918
+ const client = {
919
+ putAgentAcknowledgement: async () => ({
920
+ status: 'recorded' as const,
921
+ agent_acknowledgement: {
922
+ text: 'I will deploy staging now.',
923
+ created_at: '2026-08-13T12:01:00.000Z',
924
+ },
925
+ }),
926
+ } as unknown as ApiClient
927
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-clear-'))
928
+ const deps = makeDeps(io, client)
929
+ deps.env = {
930
+ XDG_CONFIG_HOME: path.join(root, 'config'),
931
+ XDG_STATE_HOME: path.join(root, 'state'),
932
+ }
933
+ const now = 1_800_000_000_000
934
+ deps.now = () => now
935
+ writeSessionState('ack-session', deps.env, {
936
+ acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
937
+ })
938
+ writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
939
+
940
+ expect(
941
+ await acknowledgeCommand(deps, receipt.request_id, {
942
+ text: 'I will deploy staging now.',
943
+ }),
944
+ ).toBe(EXIT.ok)
945
+ expect(readSessionState('ack-session', deps.env).acknowledgement_due).toBeUndefined()
946
+ })
947
+
948
+ it('reports idempotent acknowledgement replay in concise human output', async () => {
949
+ const io = new CapturedIo()
950
+ const client = {
951
+ putAgentAcknowledgement: async () => ({
952
+ status: 'replayed' as const,
953
+ agent_acknowledgement: {
954
+ text: 'I will deploy staging now.',
955
+ created_at: '2026-08-13T12:01:00.000Z',
956
+ },
957
+ }),
958
+ } as unknown as ApiClient
959
+
960
+ expect(
961
+ await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {
962
+ text: 'I will deploy staging now.',
963
+ }),
964
+ ).toBe(EXIT.ok)
965
+ expect(io.outLines).toEqual([
966
+ `Agent Acknowledgement replayed for ${receipt.request_id} at 2026-08-13T12:01:00.000Z.`,
967
+ ])
968
+ })
969
+
970
+ it.each([
971
+ { text: undefined, message: '--text is required' },
972
+ { text: ' ', message: '--text is required' },
973
+ { text: 'x'.repeat(1001), message: 'at most 1000 characters' },
974
+ ])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
975
+ const io = new CapturedIo()
976
+ let calls = 0
977
+ const client = {
978
+ putAgentAcknowledgement: async () => {
979
+ calls += 1
980
+ throw new Error('should not be reached')
981
+ },
982
+ } as unknown as ApiClient
983
+
984
+ expect(
985
+ await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text }),
986
+ ).toBe(EXIT.usage)
987
+ expect(calls).toBe(0)
988
+ expect(io.errLines.join('\n')).toContain(message)
989
+ })
990
+
991
+ it.each([
992
+ [new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
993
+ [new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
994
+ [new ApiCallError(409, 'conflict', 'No user reply has been recorded yet.'), EXIT.failed, 'No user reply'],
995
+ [new ApiCallError(404, 'not_found', 'This request is unavailable or its content was purged.'), EXIT.failed, 'purged'],
996
+ [new ApiCallError(401, 'machine_revoked', 'This machine was revoked.'), EXIT.auth, 'machine_revoked'],
997
+ [new NetworkError('Could not reach the service'), EXIT.network, 'Could not reach'],
998
+ ] as const)(
999
+ 'maps stable acknowledgement failure %#',
1000
+ async (error, expectedExit, expectedCopy) => {
1001
+ const io = new CapturedIo()
1002
+ const client = {
1003
+ putAgentAcknowledgement: async () => {
1004
+ throw error
1005
+ },
1006
+ } as unknown as ApiClient
1007
+
1008
+ expect(
1009
+ await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text: 'Next work.' }),
1010
+ ).toBe(expectedExit)
1011
+ expect(io.errLines.join('\n')).toContain(expectedCopy)
1012
+ },
1013
+ )
1014
+
1015
+ it('reports required-but-not-yet-due state before any user reply', async () => {
1016
+ const io = new CapturedIo()
1017
+ const client = {
1018
+ replies: async () => replyResponse([]),
1019
+ } as unknown as ApiClient
1020
+
1021
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.noReply)
1022
+ expect(io.outLines).toContain(
1023
+ 'Agent Acknowledgement: required after a user reply; no reply is recorded yet.',
1024
+ )
1025
+ expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
1026
+ })
1027
+
1028
+ it('exposes disabled acknowledgement state without a follow-up command', async () => {
1029
+ const io = new CapturedIo()
1030
+ const client = {
1031
+ replies: async () => replyResponse([reply], { required: false }),
1032
+ } as unknown as ApiClient
1033
+
1034
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1035
+ expect(io.outLines).toContain('Agent Acknowledgement: not required for this request.')
1036
+ expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
1037
+ })
1038
+
1039
+ it('exposes an existing acknowledgement without repeating the follow-up command', async () => {
1040
+ const io = new CapturedIo()
1041
+ const acknowledgement = {
1042
+ text: 'I will deploy staging now.',
1043
+ created_at: '2026-08-13T12:01:00.000Z',
1044
+ }
1045
+ const client = {
1046
+ replies: async () => replyResponse([reply], { acknowledgement }),
1047
+ } as unknown as ApiClient
1048
+
1049
+ expect(
1050
+ await repliesCommand(makeDeps(io, client), receipt.request_id, { json: true }),
1051
+ ).toBe(EXIT.ok)
1052
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1053
+ agent_acknowledgement_required: true,
1054
+ agent_acknowledgement: acknowledgement,
1055
+ acknowledgement_command: null,
1056
+ })
1057
+ })
1058
+
1059
+ it('close exposes the acknowledgement requirement and exact follow-up command', async () => {
1060
+ const io = new CapturedIo()
1061
+ const client = {
1062
+ closeReplies: async () => replyResponse([reply]),
1063
+ } as unknown as ApiClient
1064
+
1065
+ expect(await closeCommand(makeDeps(io, client), receipt.request_id, { json: true })).toBe(
1066
+ EXIT.ok,
1067
+ )
1068
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1069
+ request_id: receipt.request_id,
1070
+ agent_acknowledgement_required: true,
1071
+ agent_acknowledgement: null,
1072
+ acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
1073
+ })
1074
+ })
1075
+
835
1076
  it('passes the replies cursor and prints replies for later retrieval', async () => {
836
1077
  const io = new CapturedIo()
837
1078
  let requested: { waitSeconds: number; afterSeq: number } | undefined
@@ -851,7 +1092,11 @@ describe('command contracts', () => {
851
1092
 
852
1093
  expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
853
1094
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
854
- expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
1095
+ expect(io.outLines).toEqual([
1096
+ 'reply from iPhone: yes, after the migration',
1097
+ 'Agent Acknowledgement required.',
1098
+ `next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
1099
+ ])
855
1100
  expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
856
1101
  })
857
1102
 
@@ -876,6 +1121,8 @@ describe('command contracts', () => {
876
1121
  expect(io.outLines).toEqual([
877
1122
  `pending request ${receipt.request_id}`,
878
1123
  'reply from iPhone: yes, after the migration',
1124
+ 'Agent Acknowledgement required.',
1125
+ `next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
879
1126
  ])
880
1127
  })
881
1128
 
@@ -2156,7 +2403,7 @@ describe('init', () => {
2156
2403
  platform: 'ios' as const,
2157
2404
  permission_status: 'authorized',
2158
2405
  registration_healthy: true,
2159
- reply_protocol_version: 1,
2406
+ reply_protocol_version: 2,
2160
2407
  last_seen_at: '2026-08-05T18:00:00.000Z',
2161
2408
  }
2162
2409
 
package/src/commands.ts CHANGED
@@ -4,6 +4,7 @@ import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
6
6
  import {
7
+ AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
7
8
  CAPABILITIES_V1,
8
9
  QUESTION_TEXT_MAX_LENGTH,
9
10
  REPLY_MAX_QUESTIONS,
@@ -65,6 +66,7 @@ import {
65
66
  import type { Tone } from './ui/theme.js'
66
67
  import {
67
68
  WAITER_CEILING_SECONDS,
69
+ clearAcknowledgementObligation,
68
70
  handleSessionEnd,
69
71
  handleStop,
70
72
  handleUserPromptSubmit,
@@ -685,6 +687,7 @@ export async function sendCommand(
685
687
  title: flags.title,
686
688
  overall: receipt.overall,
687
689
  replayed: receipt.replayed,
690
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
688
691
  deliveries: receipt.deliveries.length,
689
692
  wait_seconds: waitSeconds,
690
693
  exit: receiptExit,
@@ -715,6 +718,9 @@ export async function sendCommand(
715
718
  type: 'reply_result',
716
719
  request_id: receipt.request_id,
717
720
  replies: [],
721
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
722
+ agent_acknowledgement: null,
723
+ acknowledgement_command: null,
718
724
  degraded: false,
719
725
  })
720
726
  : JSON.stringify(receipt, null, 2),
@@ -733,15 +739,15 @@ export async function sendCommand(
733
739
  recordReplies(deps, receipt.request_id, result.response.replies)
734
740
  if (flags.json) {
735
741
  deps.io.out(
736
- JSON.stringify({
737
- type: 'reply_result',
738
- request_id: receipt.request_id,
739
- replies: result.response.replies,
740
- degraded: result.degraded,
741
- }),
742
+ JSON.stringify(replyResultJson(result.response, result.degraded)),
742
743
  )
743
- } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
744
- else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
744
+ } else if (result.response.replies.length > 0) {
745
+ printReplies(deps, result.response.replies)
746
+ printAcknowledgementStatus(deps, result.response)
747
+ } else {
748
+ printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
749
+ printAcknowledgementStatus(deps, result.response)
750
+ }
745
751
  if (result.degraded) {
746
752
  log(deps).error('cli.error', {
747
753
  kind: 'network',
@@ -893,12 +899,14 @@ export async function repliesCommand(
893
899
  })
894
900
  recordReplies(deps, requestId, result.response.replies)
895
901
  if (flags.json) {
896
- jsonBodies.push({ ...result.response, degraded: result.degraded })
902
+ jsonBodies.push(replyResultJson(result.response, result.degraded))
897
903
  } else if (result.response.replies.length > 0) {
898
904
  if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
899
905
  printReplies(deps, result.response.replies)
906
+ printAcknowledgementStatus(deps, result.response)
900
907
  } else {
901
908
  printNoReply(deps, requestId, result.response.reply_expires_at)
909
+ printAcknowledgementStatus(deps, result.response)
902
910
  }
903
911
  anyReplies ||= result.response.replies.length > 0
904
912
  if (result.degraded) degradedRequestIds.push(requestId)
@@ -996,7 +1004,13 @@ export async function waitForReply(
996
1004
  return {
997
1005
  response:
998
1006
  lastResponse ??
999
- ({ request_id: requestId, reply_expires_at: null, replies: [] } satisfies ListRepliesResponse),
1007
+ ({
1008
+ request_id: requestId,
1009
+ reply_expires_at: null,
1010
+ agent_acknowledgement_required: false,
1011
+ agent_acknowledgement: null,
1012
+ replies: [],
1013
+ } satisfies ListRepliesResponse),
1000
1014
  timedOut: true,
1001
1015
  // A poll succeeded at some point, so we do not throw — but the last thing
1002
1016
  // we know is that we could not reach the server. Reporting that as a plain
@@ -1036,6 +1050,56 @@ function isNonNegativeInteger(value: number): boolean {
1036
1050
  return Number.isInteger(value) && value >= 0
1037
1051
  }
1038
1052
 
1053
+ function acknowledgementCommand(
1054
+ requestId: string,
1055
+ required: boolean,
1056
+ acknowledgement: ListRepliesResponse['agent_acknowledgement'],
1057
+ hasReply = true,
1058
+ ): string | null {
1059
+ return required && acknowledgement === null && hasReply
1060
+ ? `notifai acknowledge ${requestId} --text <text>`
1061
+ : null
1062
+ }
1063
+
1064
+ function replyResultJson(response: ListRepliesResponse, degraded: boolean): object {
1065
+ return {
1066
+ type: 'reply_result',
1067
+ request_id: response.request_id,
1068
+ reply_expires_at: response.reply_expires_at,
1069
+ replies: response.replies,
1070
+ agent_acknowledgement_required: response.agent_acknowledgement_required,
1071
+ agent_acknowledgement: response.agent_acknowledgement,
1072
+ acknowledgement_command: acknowledgementCommand(
1073
+ response.request_id,
1074
+ response.agent_acknowledgement_required,
1075
+ response.agent_acknowledgement,
1076
+ response.replies.length > 0,
1077
+ ),
1078
+ degraded,
1079
+ }
1080
+ }
1081
+
1082
+ function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResponse): void {
1083
+ if (!response.agent_acknowledgement_required) {
1084
+ deps.io.out('Agent Acknowledgement: not required for this request.')
1085
+ return
1086
+ }
1087
+ if (response.agent_acknowledgement !== null) {
1088
+ deps.io.out(
1089
+ `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`,
1090
+ )
1091
+ return
1092
+ }
1093
+ if (response.replies.length > 0) {
1094
+ deps.io.out('Agent Acknowledgement required.')
1095
+ deps.io.out(
1096
+ `next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`,
1097
+ )
1098
+ return
1099
+ }
1100
+ deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.')
1101
+ }
1102
+
1039
1103
  function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
1040
1104
  const logger = log(deps)
1041
1105
  for (const reply of replies) {
@@ -2123,14 +2187,95 @@ function hookActivationAdvice(installations: Installation[]): string {
2123
2187
  return `${advice.join('. ')}.`
2124
2188
  }
2125
2189
 
2190
+ /** Record the one Agent Acknowledgement associated with a replied-to request. */
2191
+ export async function acknowledgeCommand(
2192
+ deps: CommandDeps,
2193
+ requestId: string,
2194
+ flags: { text?: string; json?: boolean },
2195
+ ): Promise<number> {
2196
+ const text = flags.text?.trim() ?? ''
2197
+ if (text.length === 0) {
2198
+ deps.io.err('--text is required and must contain non-whitespace text.')
2199
+ return EXIT.usage
2200
+ }
2201
+ if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
2202
+ deps.io.err(
2203
+ `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`,
2204
+ )
2205
+ return EXIT.usage
2206
+ }
2207
+
2208
+ const logger = log(deps)
2209
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2210
+ logger.info('acknowledgement.attempted', {
2211
+ request_id: requestId,
2212
+ text,
2213
+ characters: text.length,
2214
+ })
2215
+ const authed = authedClient(deps, config)
2216
+ if (!authed) return EXIT.auth
2217
+ try {
2218
+ const result = await authed.client.putAgentAcknowledgement(requestId, { text })
2219
+ const output = {
2220
+ request_id: requestId,
2221
+ outcome: result.status,
2222
+ acknowledgement: result.agent_acknowledgement,
2223
+ agent_acknowledgement_required: true,
2224
+ }
2225
+ logger.info('acknowledgement.outcome', {
2226
+ request_id: requestId,
2227
+ outcome: result.status,
2228
+ text: result.agent_acknowledgement.text,
2229
+ created_at: result.agent_acknowledgement.created_at,
2230
+ agent_acknowledgement_required: true,
2231
+ })
2232
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
2233
+ if (sessionId !== null) {
2234
+ clearAcknowledgementObligation(sessionId, deps.env, requestId)
2235
+ }
2236
+ if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
2237
+ else {
2238
+ deps.io.out(
2239
+ `Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`,
2240
+ )
2241
+ }
2242
+ return EXIT.ok
2243
+ } catch (err) {
2244
+ return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId })
2245
+ }
2246
+ }
2247
+
2126
2248
  /** Retire a question so a late answer is rejected rather than silently lost. */
2127
- export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
2249
+ export async function closeCommand(
2250
+ deps: CommandDeps,
2251
+ requestId: string,
2252
+ flags: { json?: boolean } = {},
2253
+ ): Promise<number> {
2128
2254
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2129
2255
  const authed = authedClient(deps, config)
2130
2256
  if (!authed) return EXIT.auth
2131
2257
  try {
2132
- await authed.client.closeReplies(requestId)
2133
- deps.io.out(`Closed the reply window for ${requestId}.`)
2258
+ const response = await authed.client.closeReplies(requestId)
2259
+ if (flags.json) {
2260
+ deps.io.out(
2261
+ JSON.stringify(
2262
+ {
2263
+ ...response,
2264
+ acknowledgement_command: acknowledgementCommand(
2265
+ response.request_id,
2266
+ response.agent_acknowledgement_required,
2267
+ response.agent_acknowledgement,
2268
+ response.replies.length > 0,
2269
+ ),
2270
+ },
2271
+ null,
2272
+ 2,
2273
+ ),
2274
+ )
2275
+ } else {
2276
+ deps.io.out(`Closed the reply window for ${requestId}.`)
2277
+ if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
2278
+ }
2134
2279
  return EXIT.ok
2135
2280
  } catch (err) {
2136
2281
  return reportError(deps, err)