@atalk/sdk 0.1.0-alpha.10 → 0.1.0-alpha.12

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/dist/agent.js CHANGED
@@ -1,16 +1,40 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, resolve } from "node:path";
4
- import { decodeAttachmentMessage, decodeDirectedMessage, decryptAttachment, attachmentPartDescriptors, encodeAgentActivity, encodeAttachmentMessage, encryptAttachment, joinEncryptedAttachmentParts, serverFrameSchema, splitEncryptedAttachment, } from "@atalk/protocol";
4
+ import { decodeAttachmentMessage, createChunkedAttachmentDescriptor, decryptAttachmentChunk, decodeDirectedMessage, decryptAttachment, attachmentPartDescriptors, encodeAgentActivity, encodeAttachmentMessage, encryptAttachment, encryptAttachmentChunk, joinEncryptedAttachmentParts, serverFrameSchema, splitEncryptedAttachment, } from "@atalk/protocol";
5
5
  import WebSocket from "ws";
6
- import { FileCredentialStore } from "./credential-store.js";
6
+ import { FileCredentialStore, } from "./credential-store.js";
7
7
  import { decryptTextNative, encryptTextNative, generateIdentityKeysNative } from "./native-core.js";
8
+ import { emptyRuntimeState, FileRuntimeStateStore, MemoryRuntimeStateStore, } from "./runtime-state-store.js";
9
+ import { WorkroomClient } from "./workrooms.js";
10
+ const MAX_PROCESSED_INCOMING = 10_000;
11
+ const DEFAULT_REFRESH_LEEWAY_MS = 5 * 60_000;
12
+ const FATAL_SESSION_CODES = new Set([
13
+ "AUTH_REQUIRED",
14
+ "INVALID_REFRESH_TOKEN",
15
+ "INVALID_SESSION",
16
+ "PEER_INACTIVE",
17
+ ]);
8
18
  export class Agent {
19
+ /** Durable, E2EE task/workroom API for this agent identity. */
20
+ workrooms;
9
21
  baseUrl;
10
22
  activationToken;
11
23
  credentialStore;
24
+ runtimeStateStore;
25
+ credentialRefresher;
26
+ usesDefaultCredentialRefresher;
27
+ refreshLeewayMs;
12
28
  supervisionEnabled;
13
29
  credentials;
30
+ runtimeState = emptyRuntimeState();
31
+ stateMutation = Promise.resolve();
32
+ refreshPromise;
33
+ outboxDrain;
34
+ inboxDrain;
35
+ inboxRetryTimer;
36
+ inboxRetryAttempt = 0;
37
+ sentThisConnection = new Set();
14
38
  socket;
15
39
  ready = false;
16
40
  reconnectAttempt = 0;
@@ -19,11 +43,31 @@ export class Agent {
19
43
  errorHandler;
20
44
  supervisors = [];
21
45
  counterparties = new Map();
46
+ processingIncoming = new Map();
22
47
  constructor(options) {
23
48
  this.activationToken = options.token;
24
49
  this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:4001").replace(/\/$/u, "");
25
50
  this.credentialStore = options.credentialStore ?? new FileCredentialStore(options.token, options.credentialPath);
51
+ this.runtimeStateStore = options.runtimeStateStore
52
+ ?? (options.runtimeStatePath
53
+ ? new FileRuntimeStateStore(options.runtimeStatePath)
54
+ : this.credentialStore instanceof FileCredentialStore
55
+ ? new FileRuntimeStateStore(`${this.credentialStore.path}.runtime.json`)
56
+ : new MemoryRuntimeStateStore());
57
+ this.usesDefaultCredentialRefresher = !options.refreshCredentials;
58
+ this.credentialRefresher = options.refreshCredentials ?? refreshAtalkCredentials;
59
+ this.refreshLeewayMs = Math.max(0, options.refreshLeewayMs ?? DEFAULT_REFRESH_LEEWAY_MS);
26
60
  this.supervisionEnabled = options.supervision ?? true;
61
+ this.workrooms = new WorkroomClient({
62
+ request: (path, init) => this.request(path, init),
63
+ credentials: () => this.requireCredentials(),
64
+ runtimeState: () => this.runtimeState,
65
+ mutateRuntimeState: (mutator) => this.mutateRuntimeState(mutator),
66
+ uploadPart: (scope, id, bytes, transfer) => this.uploadAttachment(scope, id, bytes, transfer),
67
+ deletePart: (id) => this.deleteAttachmentPart(id),
68
+ downloadAttachment: (descriptor) => this.downloadAttachment(descriptor),
69
+ downloadAttachmentTo: (descriptor, path, transfer) => this.downloadAttachmentTo(descriptor, path, transfer),
70
+ });
27
71
  }
28
72
  get connected() {
29
73
  return this.ready && this.socket?.readyState === WebSocket.OPEN;
@@ -40,18 +84,37 @@ export class Agent {
40
84
  }
41
85
  async start() {
42
86
  this.stopped = false;
43
- this.credentials = (await this.credentialStore.load()) ?? (await this.activate());
44
- if (this.supervisionEnabled) {
45
- const result = await this.request("/v1/agent-runtime/supervisors");
46
- this.supervisors = result.supervisors;
87
+ this.inboxRetryAttempt = 0;
88
+ this.runtimeState = (await this.runtimeStateStore.load()) ?? emptyRuntimeState();
89
+ const persistedCredentials = await this.credentialStore.load();
90
+ this.credentials = persistedCredentials ?? (await this.activate());
91
+ this.counterparties.clear();
92
+ for (const [conversationId, peer] of Object.entries(this.runtimeState.counterparties)) {
93
+ this.counterparties.set(conversationId, peer);
94
+ }
95
+ try {
96
+ await this.prepareAndConnect();
97
+ }
98
+ catch (error) {
99
+ // A newly issued connection code is also the recovery path after an
100
+ // owner revoked this runtime's sessions. Reuse the locally persisted
101
+ // identity keys: silently generating a new pair would strand encrypted
102
+ // Task history and change the agent's cryptographic identity.
103
+ if (!persistedCredentials || !this.activationToken || !isSessionError(error))
104
+ throw error;
105
+ this.credentials = await this.activate(persistedCredentials.keys);
106
+ await this.prepareAndConnect();
47
107
  }
48
- await this.connect();
49
108
  }
50
109
  async stop() {
51
110
  this.stopped = true;
52
111
  this.ready = false;
53
112
  this.reconnectAttempt = 0;
113
+ if (this.inboxRetryTimer)
114
+ clearTimeout(this.inboxRetryTimer);
115
+ this.inboxRetryTimer = undefined;
54
116
  this.socket?.close(1000, "Agent stopped");
117
+ await this.stateMutation;
55
118
  }
56
119
  async send(recipientHandle, text) {
57
120
  return (await this.sendWithDetails(recipientHandle, text)).conversationId;
@@ -78,31 +141,82 @@ export class Agent {
78
141
  return (await this.sendAttachmentFileWithDetails(recipientHandle, input)).conversationId;
79
142
  }
80
143
  async sendAttachmentFileWithDetails(recipientHandle, input) {
81
- return this.sendAttachmentWithDetails(recipientHandle, await attachmentInputFromFile(input));
144
+ const conversationId = randomUUID();
145
+ const messageId = await this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
146
+ return { conversationId, messageId };
82
147
  }
83
148
  async sendAttachmentInConversation(recipientHandle, input, conversationId) {
84
149
  return this.sendAttachmentEnvelope(recipientHandle, input, conversationId);
85
150
  }
86
151
  async sendAttachmentFileInConversation(recipientHandle, input, conversationId) {
87
- return this.sendAttachmentInConversation(recipientHandle, await attachmentInputFromFile(input), conversationId);
152
+ return this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
153
+ }
154
+ /** Download an attachment descriptor retained by a durable bridge. */
155
+ async downloadAttachment(descriptor) {
156
+ const parts = [];
157
+ for (const part of attachmentPartDescriptors(descriptor)) {
158
+ const response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${part.id}`);
159
+ if (!response.ok)
160
+ throw await responseError(response);
161
+ parts.push(new Uint8Array(await response.arrayBuffer()));
162
+ }
163
+ return decryptAttachment(joinEncryptedAttachmentParts(parts, descriptor), descriptor);
88
164
  }
89
- async activate() {
165
+ /** Stream-decrypt an attachment into an atomic local file without buffering the whole payload. */
166
+ async downloadAttachmentTo(descriptor, filePath, options) {
167
+ return this.downloadAttachmentToFile(descriptor, filePath, options);
168
+ }
169
+ /** Mark an incoming message as read when only its durable id is available. */
170
+ async markMessageRead(messageId) {
171
+ await this.completeIncoming(messageId, "READ");
172
+ this.sendFrame({ kind: "ACK", messageId, state: "READ" });
173
+ this.scheduleInboxRetry();
174
+ }
175
+ async activate(existingKeys) {
90
176
  if (!this.activationToken) {
91
177
  throw new Error("ACTIVATION_REQUIRED: Provide a one-time token because no persisted credentials were found");
92
178
  }
93
- const keys = generateIdentityKeysNative();
179
+ const remembered = this.runtimeState.pendingActivation;
180
+ const pending = remembered && (!existingKeys || sameIdentityKeys(remembered.keys, existingKeys))
181
+ ? remembered
182
+ : {
183
+ requestId: randomUUID(),
184
+ keys: existingKeys ?? generateIdentityKeysNative(),
185
+ };
186
+ if (pending !== remembered) {
187
+ await this.mutateRuntimeState((state) => { state.pendingActivation = pending; });
188
+ }
94
189
  const response = await this.request("/v1/agents/activate", {
95
190
  method: "POST",
96
191
  body: JSON.stringify({
97
192
  activationToken: this.activationToken,
98
- signingPublicKey: keys.signingPublicKey,
99
- encryptionPublicKey: keys.encryptionPublicKey,
193
+ activationRequestId: pending.requestId,
194
+ signingPublicKey: pending.keys.signingPublicKey,
195
+ encryptionPublicKey: pending.keys.encryptionPublicKey,
100
196
  }),
101
197
  }, false);
102
- const credentials = { sessionToken: response.token, peer: response.peer, keys };
198
+ const access = response.accessToken ?? response.token;
199
+ const accessTokenExpiresAt = response.accessTokenExpiresAt ?? response.expiresAt;
200
+ const credentials = {
201
+ sessionToken: access,
202
+ accessToken: access,
203
+ ...(response.refreshToken ? { refreshToken: response.refreshToken } : {}),
204
+ ...(accessTokenExpiresAt ? { accessTokenExpiresAt } : {}),
205
+ peer: response.peer,
206
+ keys: pending.keys,
207
+ };
103
208
  await this.credentialStore.save(credentials);
209
+ await this.mutateRuntimeState((state) => { delete state.pendingActivation; });
104
210
  return credentials;
105
211
  }
212
+ async prepareAndConnect() {
213
+ await this.refreshCredentialsIfNeeded("EXPIRING");
214
+ if (this.supervisionEnabled) {
215
+ const result = await this.request("/v1/agent-runtime/supervisors");
216
+ this.supervisors = result.supervisors;
217
+ }
218
+ await this.connectWithRefresh();
219
+ }
106
220
  async connect() {
107
221
  const credentials = this.requireCredentials();
108
222
  const websocketUrl = `${this.baseUrl.replace(/^http/u, "ws")}/v1/ws`;
@@ -117,7 +231,7 @@ export class Agent {
117
231
  reject(new Error("aTalk connection timed out"));
118
232
  }
119
233
  }, 10_000);
120
- socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: credentials.sessionToken })));
234
+ socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: accessToken(credentials) })));
121
235
  socket.on("message", (raw) => {
122
236
  const frame = serverFrameSchema.parse(JSON.parse(raw.toString()));
123
237
  void this.handleFrame(frame).then(() => {
@@ -125,8 +239,11 @@ export class Agent {
125
239
  ready = true;
126
240
  this.ready = true;
127
241
  this.reconnectAttempt = 0;
242
+ this.sentThisConnection.clear();
128
243
  clearTimeout(timeout);
129
244
  resolve();
245
+ void this.drainOutbox().catch((error) => this.emitError(error));
246
+ void this.drainInbox();
130
247
  }
131
248
  }).catch((error) => {
132
249
  if (!ready) {
@@ -136,6 +253,7 @@ export class Agent {
136
253
  }
137
254
  else {
138
255
  this.emitError(error);
256
+ this.scheduleInboxRetry();
139
257
  }
140
258
  });
141
259
  });
@@ -151,15 +269,14 @@ export class Agent {
151
269
  if (this.socket === socket)
152
270
  this.ready = false;
153
271
  if (!ready) {
154
- const error = new Error(code === 4001 || code === 1008 ? "INVALID_SESSION: Agent credentials were revoked" : "aTalk connection closed before authentication");
155
- if (code === 4001 || code === 1008)
156
- this.stopped = true;
272
+ const error = code === 4001 || code === 1008
273
+ ? new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected")
274
+ : new Error("aTalk connection closed before authentication");
157
275
  reject(error);
158
276
  return;
159
277
  }
160
278
  if (code === 4001 || code === 1008) {
161
- this.stopped = true;
162
- this.emitError(new Error("INVALID_SESSION: Agent credentials were revoked"));
279
+ void this.recoverRejectedSession();
163
280
  return;
164
281
  }
165
282
  if (!this.stopped && ready)
@@ -174,7 +291,9 @@ export class Agent {
174
291
  setTimeout(() => {
175
292
  if (this.stopped)
176
293
  return;
177
- void this.connect().catch((error) => {
294
+ void this.connectWithRefresh().catch((error) => {
295
+ if (isSessionError(error))
296
+ this.stopped = true;
178
297
  this.emitError(error);
179
298
  if (!this.stopped)
180
299
  this.scheduleReconnect();
@@ -182,16 +301,45 @@ export class Agent {
182
301
  }, delay);
183
302
  }
184
303
  async handleFrame(frame) {
185
- if (frame.kind === "ERROR")
186
- throw new Error(`${frame.code}: ${frame.message}`);
304
+ if (frame.kind === "ERROR") {
305
+ throw new AgentProtocolError(frame.code, frame.message);
306
+ }
187
307
  if (frame.kind === "RECEIPT") {
308
+ await this.removeFromOutbox(frame.messageId);
188
309
  this.sendFrame({ kind: "RECEIPT_ACK", messageId: frame.messageId, state: frame.state });
189
310
  return;
190
311
  }
312
+ if (frame.kind === "ACK_RECEIVED") {
313
+ await this.forgetIncoming(frame.messageId);
314
+ return;
315
+ }
191
316
  if (frame.kind !== "MESSAGE")
192
317
  return;
318
+ const messageId = frame.envelope.message_id;
319
+ const confirmed = this.runtimeState.processedIncoming[messageId];
320
+ if (confirmed) {
321
+ this.sendFrame({ kind: "ACK", messageId, state: confirmed });
322
+ this.scheduleInboxRetry();
323
+ return;
324
+ }
325
+ await this.rememberIncoming(frame.envelope);
326
+ const existing = this.processingIncoming.get(messageId);
327
+ if (existing) {
328
+ await existing;
329
+ return;
330
+ }
331
+ const processing = this.processIncomingMessage(frame);
332
+ this.processingIncoming.set(messageId, processing);
333
+ try {
334
+ await processing;
335
+ }
336
+ finally {
337
+ this.processingIncoming.delete(messageId);
338
+ }
339
+ }
340
+ async processIncomingMessage(frame) {
193
341
  const credentials = this.requireCredentials();
194
- const sender = await this.request(`/v1/peers/${frame.envelope.sender_peer_id}/keys`);
342
+ const sender = await this.request(`/v1/messages/${frame.envelope.message_id}/sender-keys`);
195
343
  const text = decryptTextNative({
196
344
  envelope: frame.envelope,
197
345
  senderSigningPublicKey: sender.signingPublicKey,
@@ -202,61 +350,76 @@ export class Agent {
202
350
  const content = directedMessage?.content ?? text;
203
351
  const attachmentMessage = decodeAttachmentMessage(content);
204
352
  const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === sender.id);
353
+ const isMentioned = directedMessage?.mentions.some((mention) => mention.peerId === credentials.peer.id) ?? false;
354
+ const counterparty = isSupervisor ? this.counterparties.get(frame.envelope.conversation_id) : sender;
355
+ const routing = isSupervisor && !isMentioned
356
+ ? { mode: "RELAY", targetHandle: counterparty?.handle ?? "" }
357
+ : { mode: "REPLY", targetHandle: sender.handle };
205
358
  if (!isSupervisor) {
206
359
  this.counterparties.set(frame.envelope.conversation_id, sender);
207
- await this.mirrorActivity("INCOMING", sender, text, frame.envelope.conversation_id, frame.envelope.message_id, frame.envelope.timestamp);
208
- }
209
- this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: "DELIVERED" });
210
- if (this.messageHandler) {
211
- await this.messageHandler({
212
- id: frame.envelope.message_id,
213
- conversationId: frame.envelope.conversation_id,
214
- text: attachmentMessage?.caption ?? (attachmentMessage ? "" : content),
215
- ...(attachmentMessage ? { attachment: {
216
- descriptor: attachmentMessage.attachment,
217
- download: () => this.downloadAttachment(attachmentMessage.attachment),
218
- downloadTo: (filePath) => this.downloadAttachmentToFile(attachmentMessage.attachment, filePath),
219
- } } : {}),
220
- sender,
221
- receivedAt: new Date(frame.envelope.timestamp),
222
- isSupervisor,
223
- mentions: directedMessage?.mentions ?? [],
224
- isMentioned: directedMessage?.mentions.some((mention) => mention.peerId === credentials.peer.id) ?? false,
225
- reply: (replyText) => this.sendEnvelope(sender.handle, replyText, frame.envelope.conversation_id),
226
- replyAttachment: (input) => this.sendAttachmentEnvelope(sender.handle, input, frame.envelope.conversation_id),
227
- replyAttachmentFile: async (input) => this.sendAttachmentEnvelope(sender.handle, await attachmentInputFromFile(input), frame.envelope.conversation_id),
228
- relay: async (relayText) => {
229
- if (!isSupervisor)
230
- throw new Error("Only supervisor messages can be relayed");
231
- const counterparty = this.counterparties.get(frame.envelope.conversation_id);
232
- if (!counterparty)
233
- throw new Error("No active counterparty exists for this supervised conversation");
234
- return this.sendEnvelope(counterparty.handle, relayText, frame.envelope.conversation_id);
235
- },
236
- relayAttachment: async (input) => {
237
- if (!isSupervisor)
238
- throw new Error("Only supervisor messages can be relayed");
239
- const counterparty = this.counterparties.get(frame.envelope.conversation_id);
240
- if (!counterparty)
241
- throw new Error("No active counterparty exists for this supervised conversation");
242
- return this.sendAttachmentEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
243
- },
244
- relayAttachmentFile: async (input) => {
245
- if (!isSupervisor)
246
- throw new Error("Only supervisor messages can be relayed");
247
- const counterparty = this.counterparties.get(frame.envelope.conversation_id);
248
- if (!counterparty)
249
- throw new Error("No active counterparty exists for this supervised conversation");
250
- return this.sendAttachmentEnvelope(counterparty.handle, await attachmentInputFromFile(input), frame.envelope.conversation_id);
251
- },
252
- markRead: async () => this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: "READ" }),
360
+ await this.mutateRuntimeState((state) => {
361
+ state.counterparties[frame.envelope.conversation_id] = sender;
253
362
  });
363
+ await this.mirrorActivity("INCOMING", sender, text, frame.envelope.conversation_id, frame.envelope.message_id, frame.envelope.timestamp);
254
364
  }
365
+ if (!this.messageHandler)
366
+ throw new Error("MESSAGE_HANDLER_NOT_CONFIGURED: Register a message handler before start()");
367
+ let acknowledgedState = "DELIVERED";
368
+ let handlerComplete = false;
369
+ await this.messageHandler({
370
+ id: frame.envelope.message_id,
371
+ conversationId: frame.envelope.conversation_id,
372
+ text: attachmentMessage?.caption ?? (attachmentMessage ? "" : content),
373
+ ...(attachmentMessage ? { attachment: {
374
+ descriptor: attachmentMessage.attachment,
375
+ download: () => this.downloadAttachment(attachmentMessage.attachment),
376
+ downloadTo: (filePath, options) => this.downloadAttachmentToFile(attachmentMessage.attachment, filePath, options),
377
+ } } : {}),
378
+ sender,
379
+ receivedAt: new Date(frame.envelope.timestamp),
380
+ isSupervisor,
381
+ mentions: directedMessage?.mentions ?? [],
382
+ isMentioned,
383
+ reply: (replyText) => this.sendEnvelope(sender.handle, replyText, frame.envelope.conversation_id),
384
+ replyAttachment: (input) => this.sendAttachmentEnvelope(sender.handle, input, frame.envelope.conversation_id),
385
+ replyAttachmentFile: (input) => this.sendAttachmentFileEnvelope(sender.handle, input, frame.envelope.conversation_id),
386
+ relay: async (relayText) => {
387
+ if (!isSupervisor)
388
+ throw new Error("Only supervisor messages can be relayed");
389
+ if (!counterparty)
390
+ throw new Error("No active counterparty exists for this supervised conversation");
391
+ return this.sendEnvelope(counterparty.handle, relayText, frame.envelope.conversation_id);
392
+ },
393
+ relayAttachment: async (input) => {
394
+ if (!isSupervisor)
395
+ throw new Error("Only supervisor messages can be relayed");
396
+ if (!counterparty)
397
+ throw new Error("No active counterparty exists for this supervised conversation");
398
+ return this.sendAttachmentEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
399
+ },
400
+ relayAttachmentFile: async (input) => {
401
+ if (!isSupervisor)
402
+ throw new Error("Only supervisor messages can be relayed");
403
+ if (!counterparty)
404
+ throw new Error("No active counterparty exists for this supervised conversation");
405
+ return this.sendAttachmentFileEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
406
+ },
407
+ markRead: async () => {
408
+ if (!handlerComplete) {
409
+ acknowledgedState = "READ";
410
+ return;
411
+ }
412
+ await this.markMessageRead(frame.envelope.message_id);
413
+ },
414
+ routing,
415
+ });
416
+ handlerComplete = true;
417
+ await this.completeIncoming(frame.envelope.message_id, acknowledgedState);
418
+ this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: acknowledgedState });
419
+ this.scheduleInboxRetry();
255
420
  }
256
421
  async sendEnvelope(recipientHandle, text, conversationId) {
257
422
  const credentials = this.requireCredentials();
258
- if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
259
- throw new Error("Agent is not connected");
260
423
  const { recipient } = await this.request("/v1/messages/authorize", {
261
424
  method: "POST",
262
425
  body: JSON.stringify({ recipientHandle }),
@@ -272,18 +435,21 @@ export class Agent {
272
435
  senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
273
436
  recipientEncryptionPublicKey: recipient.encryptionPublicKey,
274
437
  });
275
- this.sendFrame({ kind: "DELIVER", envelope });
276
438
  const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
277
439
  if (!isSupervisor) {
278
440
  this.counterparties.set(conversationId, recipient);
441
+ await this.mutateRuntimeState((state) => {
442
+ state.counterparties[conversationId] = recipient;
443
+ });
444
+ }
445
+ await this.queueEnvelope(envelope);
446
+ if (!isSupervisor) {
279
447
  await this.mirrorActivity("OUTGOING", recipient, text, conversationId, envelope.message_id, envelope.timestamp);
280
448
  }
281
449
  return envelope.message_id;
282
450
  }
283
451
  async sendAttachmentEnvelope(recipientHandle, input, conversationId) {
284
452
  const credentials = this.requireCredentials();
285
- if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
286
- throw new Error("Agent is not connected");
287
453
  const { recipient } = await this.request("/v1/messages/authorize", {
288
454
  method: "POST",
289
455
  body: JSON.stringify({ recipientHandle }),
@@ -295,7 +461,7 @@ export class Agent {
295
461
  mimeType: input.mimeType ?? "application/octet-stream",
296
462
  }), randomUUID);
297
463
  for (const part of encrypted.parts) {
298
- await this.uploadAttachment(recipient.id, part.id, part.ciphertext);
464
+ await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, part.ciphertext);
299
465
  }
300
466
  const caption = input.caption?.trim();
301
467
  const plaintext = encodeAttachmentMessage({
@@ -313,43 +479,200 @@ export class Agent {
313
479
  senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
314
480
  recipientEncryptionPublicKey: recipient.encryptionPublicKey,
315
481
  });
316
- this.sendFrame({ kind: "DELIVER", envelope });
317
482
  const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
318
483
  if (!isSupervisor) {
319
484
  this.counterparties.set(conversationId, recipient);
485
+ await this.mutateRuntimeState((state) => {
486
+ state.counterparties[conversationId] = recipient;
487
+ });
488
+ }
489
+ await this.queueEnvelope(envelope);
490
+ if (!isSupervisor) {
320
491
  await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
321
492
  }
322
493
  return envelope.message_id;
323
494
  }
324
- async uploadAttachment(recipientPeerId, attachmentId, ciphertext) {
325
- const response = await fetch(`${this.baseUrl}/v1/attachments/${attachmentId}?recipientPeerId=${encodeURIComponent(recipientPeerId)}`, {
495
+ async sendAttachmentFileEnvelope(recipientHandle, input, conversationId) {
496
+ const credentials = this.requireCredentials();
497
+ const path = resolve(input.path);
498
+ const metadata = await stat(path);
499
+ if (!metadata.isFile())
500
+ throw new Error("ATTACHMENT_NOT_A_FILE");
501
+ const { recipient } = await this.request("/v1/messages/authorize", {
326
502
  method: "POST",
327
- headers: {
328
- authorization: `Bearer ${this.requireCredentials().sessionToken}`,
329
- "content-type": "application/octet-stream",
330
- },
331
- body: exactArrayBuffer(ciphertext),
503
+ body: JSON.stringify({ recipientHandle }),
332
504
  });
333
- if (!response.ok)
334
- throw await responseError(response);
505
+ const descriptor = createChunkedAttachmentDescriptor({
506
+ id: randomUUID(),
507
+ size: metadata.size,
508
+ name: input.name?.trim() || basename(path),
509
+ mimeType: input.mimeType?.trim() || mimeTypeFromPath(path),
510
+ nextId: randomUUID,
511
+ });
512
+ if (descriptor.version !== 2)
513
+ throw new Error("ATTACHMENT_VERSION_UNSUPPORTED");
514
+ const file = await open(path, "r");
515
+ let transferred = 0;
516
+ try {
517
+ for (let index = 0; index < descriptor.chunks.length; index += 1) {
518
+ throwIfAborted(input.transfer?.signal);
519
+ const part = descriptor.chunks[index];
520
+ const plaintext = new Uint8Array(part.plaintextSize);
521
+ const { bytesRead } = await file.read(plaintext, 0, plaintext.byteLength, transferred);
522
+ if (bytesRead !== plaintext.byteLength)
523
+ throw new Error("ATTACHMENT_SIZE_MISMATCH");
524
+ await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, encryptAttachmentChunk(plaintext, descriptor, index), input.transfer);
525
+ transferred += bytesRead;
526
+ input.transfer?.onProgress?.({
527
+ phase: "UPLOADING",
528
+ bytesTransferred: transferred,
529
+ totalBytes: descriptor.size,
530
+ partIndex: index + 1,
531
+ partCount: descriptor.chunks.length,
532
+ });
533
+ }
534
+ }
535
+ catch (error) {
536
+ // Delete every id in the unpublished descriptor. This also removes a part
537
+ // whose POST reached the server but whose response was lost in transit.
538
+ await Promise.allSettled(descriptor.chunks.map((part) => this.deleteAttachmentPart(part.id)));
539
+ throw error;
540
+ }
541
+ finally {
542
+ await file.close();
543
+ }
544
+ const caption = input.caption?.trim();
545
+ const plaintext = encodeAttachmentMessage({ attachment: descriptor, ...(caption ? { caption } : {}) });
546
+ const envelope = encryptTextNative({
547
+ messageId: randomUUID(),
548
+ conversationId,
549
+ senderPeerId: credentials.peer.id,
550
+ recipientPeerId: recipient.id,
551
+ timestamp: new Date().toISOString(),
552
+ plaintext,
553
+ senderSigningSecretKey: credentials.keys.signingSecretKey,
554
+ senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
555
+ recipientEncryptionPublicKey: recipient.encryptionPublicKey,
556
+ });
557
+ const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
558
+ if (!isSupervisor) {
559
+ this.counterparties.set(conversationId, recipient);
560
+ await this.mutateRuntimeState((state) => { state.counterparties[conversationId] = recipient; });
561
+ }
562
+ await this.queueEnvelope(envelope);
563
+ if (!isSupervisor) {
564
+ await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
565
+ }
566
+ return envelope.message_id;
335
567
  }
336
- async downloadAttachment(descriptor) {
337
- const parts = [];
338
- for (const part of attachmentPartDescriptors(descriptor)) {
339
- const response = await fetch(`${this.baseUrl}/v1/attachments/${part.id}`, {
340
- headers: { authorization: `Bearer ${this.requireCredentials().sessionToken}` },
341
- });
342
- if (!response.ok)
343
- throw await responseError(response);
344
- parts.push(new Uint8Array(await response.arrayBuffer()));
568
+ async uploadAttachment(scope, attachmentId, ciphertext, options) {
569
+ throwIfAborted(options?.signal);
570
+ const existing = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${attachmentId}`, {
571
+ method: "HEAD", ...(options?.signal ? { signal: options.signal } : {}),
572
+ }).catch(() => undefined);
573
+ if (existing?.ok)
574
+ return;
575
+ const attempts = Math.max(1, Math.min(5, options?.maxAttempts ?? 3));
576
+ let lastError;
577
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
578
+ throwIfAborted(options?.signal);
579
+ let response;
580
+ try {
581
+ response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${attachmentId}?${"recipientPeerId" in scope
582
+ ? `recipientPeerId=${encodeURIComponent(scope.recipientPeerId)}`
583
+ : `workroomId=${encodeURIComponent(scope.workroomId)}`}`, {
584
+ method: "POST",
585
+ headers: { "content-type": "application/octet-stream" },
586
+ body: exactArrayBuffer(ciphertext),
587
+ ...(options?.signal ? { signal: options.signal } : {}),
588
+ });
589
+ }
590
+ catch (error) {
591
+ if (options?.signal?.aborted)
592
+ throw abortError();
593
+ lastError = error;
594
+ if (attempt + 1 < attempts)
595
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
596
+ continue;
597
+ }
598
+ if (response.ok)
599
+ return;
600
+ lastError = await responseError(response);
601
+ if (response.status < 500 && response.status !== 429)
602
+ throw lastError;
603
+ if (attempt + 1 < attempts)
604
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
345
605
  }
346
- return decryptAttachment(joinEncryptedAttachmentParts(parts, descriptor), descriptor);
606
+ throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_UPLOAD_FAILED");
347
607
  }
348
- async downloadAttachmentToFile(descriptor, filePath) {
608
+ async deleteAttachmentPart(id) {
609
+ await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, { method: "DELETE" }).catch(() => undefined);
610
+ }
611
+ async downloadAttachmentToFile(descriptor, filePath, options) {
349
612
  const target = resolve(filePath);
350
613
  await mkdir(dirname(target), { recursive: true });
351
- await writeFile(target, await this.downloadAttachment(descriptor), { mode: 0o600 });
352
- return target;
614
+ if (descriptor.version === 1) {
615
+ throwIfAborted(options?.signal);
616
+ await writeFile(target, await this.downloadAttachment(descriptor), { mode: 0o600 });
617
+ options?.onProgress?.({ phase: "DOWNLOADING", bytesTransferred: descriptor.size, totalBytes: descriptor.size, partIndex: 1, partCount: 1 });
618
+ return target;
619
+ }
620
+ const chunks = descriptor.chunks;
621
+ const temporary = `${target}.atalk-${randomUUID()}.part`;
622
+ const file = await open(temporary, "wx", 0o600);
623
+ let transferred = 0;
624
+ try {
625
+ for (let index = 0; index < chunks.length; index += 1) {
626
+ throwIfAborted(options?.signal);
627
+ const part = chunks[index];
628
+ const ciphertext = await this.downloadAttachmentPart(part.id, options);
629
+ const plaintext = decryptAttachmentChunk(ciphertext, descriptor, index);
630
+ await file.write(plaintext);
631
+ transferred += plaintext.byteLength;
632
+ options?.onProgress?.({
633
+ phase: "DOWNLOADING", bytesTransferred: transferred, totalBytes: descriptor.size,
634
+ partIndex: index + 1, partCount: chunks.length,
635
+ });
636
+ }
637
+ await file.sync();
638
+ await file.close();
639
+ await rename(temporary, target);
640
+ return target;
641
+ }
642
+ catch (error) {
643
+ await file.close().catch(() => undefined);
644
+ await rm(temporary, { force: true }).catch(() => undefined);
645
+ throw error;
646
+ }
647
+ }
648
+ async downloadAttachmentPart(id, options) {
649
+ const attempts = Math.max(1, Math.min(5, options?.maxAttempts ?? 3));
650
+ let lastError;
651
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
652
+ throwIfAborted(options?.signal);
653
+ let response;
654
+ try {
655
+ response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, {
656
+ ...(options?.signal ? { signal: options.signal } : {}),
657
+ });
658
+ }
659
+ catch (error) {
660
+ if (options?.signal?.aborted)
661
+ throw abortError();
662
+ lastError = error;
663
+ if (attempt + 1 < attempts)
664
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
665
+ continue;
666
+ }
667
+ if (response.ok)
668
+ return new Uint8Array(await response.arrayBuffer());
669
+ lastError = await responseError(response);
670
+ if (response.status < 500 && response.status !== 429)
671
+ throw lastError;
672
+ if (attempt + 1 < attempts)
673
+ await abortableDelay(250 * (2 ** attempt), options?.signal);
674
+ }
675
+ throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_DOWNLOAD_FAILED");
353
676
  }
354
677
  async mirrorActivity(direction, counterparty, text, conversationId, sourceMessageId, observedAt) {
355
678
  if (!this.supervisionEnabled || this.supervisors.length === 0)
@@ -370,7 +693,7 @@ export class Agent {
370
693
  });
371
694
  for (const supervisor of this.supervisors) {
372
695
  const envelope = encryptTextNative({
373
- messageId: randomUUID(),
696
+ messageId: deterministicUuid(`${sourceMessageId}:${supervisor.id}:${direction}:activity`),
374
697
  conversationId,
375
698
  senderPeerId: credentials.peer.id,
376
699
  recipientPeerId: supervisor.id,
@@ -380,28 +703,241 @@ export class Agent {
380
703
  senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
381
704
  recipientEncryptionPublicKey: supervisor.encryptionPublicKey,
382
705
  });
383
- this.sendFrame({ kind: "DELIVER", envelope });
706
+ await this.queueEnvelope(envelope);
707
+ }
708
+ }
709
+ async queueEnvelope(envelope) {
710
+ await this.mutateRuntimeState((state) => {
711
+ if (!state.outbox.some((item) => item.message_id === envelope.message_id))
712
+ state.outbox.push(envelope);
713
+ });
714
+ if (this.connected)
715
+ await this.drainOutbox();
716
+ }
717
+ async removeFromOutbox(messageId) {
718
+ if (!this.runtimeState.outbox.some((item) => item.message_id === messageId))
719
+ return;
720
+ await this.mutateRuntimeState((state) => {
721
+ state.outbox = state.outbox.filter((item) => item.message_id !== messageId);
722
+ });
723
+ this.sentThisConnection.delete(messageId);
724
+ }
725
+ async drainOutbox() {
726
+ if (this.outboxDrain)
727
+ return this.outboxDrain;
728
+ const drain = (async () => {
729
+ while (this.connected) {
730
+ const envelope = this.runtimeState.outbox.find((item) => !this.sentThisConnection.has(item.message_id));
731
+ if (!envelope)
732
+ return;
733
+ this.sendFrame({ kind: "DELIVER", envelope });
734
+ this.sentThisConnection.add(envelope.message_id);
735
+ }
736
+ })();
737
+ this.outboxDrain = drain;
738
+ try {
739
+ await drain;
740
+ }
741
+ finally {
742
+ if (this.outboxDrain === drain)
743
+ this.outboxDrain = undefined;
744
+ }
745
+ }
746
+ async rememberIncoming(envelope) {
747
+ if (this.runtimeState.inbox.some((item) => item.message_id === envelope.message_id))
748
+ return;
749
+ await this.mutateRuntimeState((runtime) => {
750
+ if (!runtime.inbox.some((item) => item.message_id === envelope.message_id))
751
+ runtime.inbox.push(envelope);
752
+ });
753
+ }
754
+ async completeIncoming(messageId, state) {
755
+ await this.mutateRuntimeState((runtime) => {
756
+ runtime.processedIncoming[messageId] = runtime.processedIncoming[messageId] === "READ" ? "READ" : state;
757
+ const ids = Object.keys(runtime.processedIncoming);
758
+ for (let index = 0; index < ids.length - MAX_PROCESSED_INCOMING; index += 1) {
759
+ delete runtime.processedIncoming[ids[index]];
760
+ }
761
+ });
762
+ }
763
+ async forgetIncoming(messageId) {
764
+ if (!this.runtimeState.inbox.some((item) => item.message_id === messageId))
765
+ return;
766
+ await this.mutateRuntimeState((runtime) => {
767
+ runtime.inbox = runtime.inbox.filter((item) => item.message_id !== messageId);
768
+ });
769
+ if (this.runtimeState.inbox.length === 0 && this.inboxRetryTimer) {
770
+ clearTimeout(this.inboxRetryTimer);
771
+ this.inboxRetryTimer = undefined;
772
+ this.inboxRetryAttempt = 0;
773
+ }
774
+ }
775
+ async drainInbox() {
776
+ if (this.inboxDrain)
777
+ return this.inboxDrain;
778
+ const drain = (async () => {
779
+ if (!this.connected) {
780
+ this.scheduleInboxRetry();
781
+ return;
782
+ }
783
+ for (const envelope of [...this.runtimeState.inbox]) {
784
+ try {
785
+ await this.handleFrame({ kind: "MESSAGE", envelope });
786
+ }
787
+ catch (error) {
788
+ this.emitError(error);
789
+ this.scheduleInboxRetry();
790
+ return;
791
+ }
792
+ }
793
+ if (this.runtimeState.inbox.length === 0)
794
+ this.inboxRetryAttempt = 0;
795
+ })();
796
+ this.inboxDrain = drain;
797
+ try {
798
+ await drain;
799
+ }
800
+ finally {
801
+ if (this.inboxDrain === drain)
802
+ this.inboxDrain = undefined;
384
803
  }
385
804
  }
805
+ scheduleInboxRetry() {
806
+ if (this.stopped || this.runtimeState.inbox.length === 0 || this.inboxRetryTimer)
807
+ return;
808
+ const delay = reconnectDelay(this.inboxRetryAttempt++);
809
+ this.inboxRetryTimer = setTimeout(() => {
810
+ this.inboxRetryTimer = undefined;
811
+ void this.drainInbox();
812
+ }, delay);
813
+ }
814
+ async mutateRuntimeState(mutator) {
815
+ const operation = this.stateMutation.then(async () => {
816
+ const next = structuredClone(this.runtimeState);
817
+ mutator(next);
818
+ await this.runtimeStateStore.save(next);
819
+ this.runtimeState = next;
820
+ });
821
+ this.stateMutation = operation.catch(() => undefined);
822
+ return operation;
823
+ }
386
824
  sendFrame(frame) {
387
825
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
388
826
  throw new Error("Agent is not connected");
389
827
  this.socket.send(JSON.stringify(frame));
390
828
  }
391
829
  async request(path, init = {}, authenticated = true) {
392
- const credentials = this.credentials;
393
- const response = await fetch(`${this.baseUrl}${path}`, {
830
+ const headers = { "content-type": "application/json", ...init.headers };
831
+ const response = authenticated
832
+ ? await this.authorizedFetch(`${this.baseUrl}${path}`, { ...init, headers })
833
+ : await fetch(`${this.baseUrl}${path}`, { ...init, headers });
834
+ const body = await response.json();
835
+ if (!response.ok)
836
+ throw new AgentProtocolError(body.error?.code ?? `HTTP_${response.status}`, body.error?.message ?? `HTTP ${response.status}`);
837
+ return body;
838
+ }
839
+ async authorizedFetch(url, init = {}, retry = true) {
840
+ await this.refreshCredentialsIfNeeded("EXPIRING");
841
+ const response = await fetch(url, {
394
842
  ...init,
395
843
  headers: {
396
- "content-type": "application/json",
397
- ...(authenticated && credentials ? { authorization: `Bearer ${credentials.sessionToken}` } : {}),
844
+ authorization: `Bearer ${accessToken(this.requireCredentials())}`,
398
845
  ...init.headers,
399
846
  },
400
847
  });
401
- const body = await response.json();
402
- if (!response.ok)
403
- throw new Error(body.error ? `${body.error.code}: ${body.error.message}` : `HTTP ${response.status}`);
404
- return body;
848
+ if (response.status === 401 && retry && await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
849
+ return this.authorizedFetch(url, init, false);
850
+ }
851
+ return response;
852
+ }
853
+ async refreshCredentialsIfNeeded(reason, force = false) {
854
+ if (!this.credentialRefresher || !this.credentials)
855
+ return false;
856
+ if (reason === "EXPIRING" && !force) {
857
+ const expiresAt = this.credentials.accessTokenExpiresAt
858
+ ? Date.parse(this.credentials.accessTokenExpiresAt)
859
+ : Number.POSITIVE_INFINITY;
860
+ if (!Number.isFinite(expiresAt) || expiresAt > Date.now() + this.refreshLeewayMs)
861
+ return false;
862
+ }
863
+ if (this.refreshPromise)
864
+ return this.refreshPromise;
865
+ const refresh = (async () => {
866
+ let current = this.requireCredentials();
867
+ if (this.usesDefaultCredentialRefresher && current.refreshToken && !current.refreshRequestId) {
868
+ current = { ...current, refreshRequestId: randomUUID() };
869
+ // Persist intent before sending. If the successful response is lost, a
870
+ // restart repeats the same server-side operation instead of reusing the
871
+ // rotated token under a new idempotency key.
872
+ await this.credentialStore.save(current);
873
+ this.credentials = current;
874
+ }
875
+ let refreshed;
876
+ try {
877
+ refreshed = await this.credentialRefresher({ credentials: current, reason, baseUrl: this.baseUrl });
878
+ }
879
+ catch (error) {
880
+ if (this.usesDefaultCredentialRefresher && current.refreshRequestId && isSessionError(error)) {
881
+ const { refreshRequestId: _failedRefreshRequest, ...restored } = current;
882
+ await this.credentialStore.save(restored);
883
+ this.credentials = restored;
884
+ }
885
+ throw error;
886
+ }
887
+ if (!refreshed)
888
+ return false;
889
+ const { accessTokenExpiresAt: _previousExpiry, refreshRequestId: _completedRefreshRequest, ...currentWithoutExpiry } = current;
890
+ const next = {
891
+ ...currentWithoutExpiry,
892
+ sessionToken: refreshed.accessToken,
893
+ accessToken: refreshed.accessToken,
894
+ ...(refreshed.refreshToken ?? current.refreshToken
895
+ ? { refreshToken: refreshed.refreshToken ?? current.refreshToken }
896
+ : {}),
897
+ ...(refreshed.accessTokenExpiresAt ? { accessTokenExpiresAt: refreshed.accessTokenExpiresAt } : {}),
898
+ };
899
+ await this.credentialStore.save(next);
900
+ this.credentials = next;
901
+ return true;
902
+ })();
903
+ this.refreshPromise = refresh;
904
+ try {
905
+ return await refresh;
906
+ }
907
+ finally {
908
+ if (this.refreshPromise === refresh)
909
+ this.refreshPromise = undefined;
910
+ }
911
+ }
912
+ async connectWithRefresh() {
913
+ try {
914
+ await this.connect();
915
+ }
916
+ catch (error) {
917
+ if (isSessionError(error) && await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
918
+ await this.connect();
919
+ return;
920
+ }
921
+ throw error;
922
+ }
923
+ }
924
+ async recoverRejectedSession() {
925
+ try {
926
+ if (!await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
927
+ this.stopped = true;
928
+ this.emitError(new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected"));
929
+ return;
930
+ }
931
+ if (!this.stopped)
932
+ await this.connectWithRefresh();
933
+ }
934
+ catch (error) {
935
+ if (isSessionError(error))
936
+ this.stopped = true;
937
+ this.emitError(error);
938
+ if (!this.stopped)
939
+ this.scheduleReconnect();
940
+ }
405
941
  }
406
942
  requireCredentials() {
407
943
  if (!this.credentials)
@@ -419,6 +955,80 @@ export class Agent {
419
955
  function exactArrayBuffer(bytes) {
420
956
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
421
957
  }
958
+ function abortError() {
959
+ const error = new Error("Attachment transfer was cancelled");
960
+ error.name = "AbortError";
961
+ return error;
962
+ }
963
+ function throwIfAborted(signal) {
964
+ if (signal?.aborted)
965
+ throw abortError();
966
+ }
967
+ async function abortableDelay(milliseconds, signal) {
968
+ throwIfAborted(signal);
969
+ await new Promise((resolveDelay, rejectDelay) => {
970
+ const timer = setTimeout(resolveDelay, milliseconds);
971
+ signal?.addEventListener("abort", () => {
972
+ clearTimeout(timer);
973
+ rejectDelay(abortError());
974
+ }, { once: true });
975
+ });
976
+ }
977
+ class AgentProtocolError extends Error {
978
+ code;
979
+ constructor(code, message) {
980
+ super(`${code}: ${message}`);
981
+ this.code = code;
982
+ }
983
+ }
984
+ function accessToken(credentials) {
985
+ return credentials.accessToken ?? credentials.sessionToken;
986
+ }
987
+ async function refreshAtalkCredentials(context) {
988
+ const refreshToken = context.credentials.refreshToken;
989
+ if (!refreshToken)
990
+ return undefined;
991
+ const response = await fetch(`${context.baseUrl}/v1/agent-runtime/session/refresh`, {
992
+ method: "POST",
993
+ headers: { "content-type": "application/json" },
994
+ body: JSON.stringify({
995
+ refreshToken,
996
+ // Deterministic per credential, so a process restart retries the same
997
+ // idempotency operation if the first response was lost.
998
+ requestId: context.credentials.refreshRequestId
999
+ ?? deterministicUuid(`atalk-agent-refresh:${refreshToken}`),
1000
+ }),
1001
+ });
1002
+ if (!response.ok)
1003
+ throw await responseError(response);
1004
+ const body = await response.json();
1005
+ const nextAccessToken = body.accessToken ?? body.token;
1006
+ if (!nextAccessToken)
1007
+ throw new Error("INVALID_REFRESH_RESPONSE: aTalk did not return an access token");
1008
+ return {
1009
+ accessToken: nextAccessToken,
1010
+ ...(body.refreshToken ? { refreshToken: body.refreshToken } : {}),
1011
+ ...(body.accessTokenExpiresAt ?? body.expiresAt
1012
+ ? { accessTokenExpiresAt: body.accessTokenExpiresAt ?? body.expiresAt }
1013
+ : {}),
1014
+ };
1015
+ }
1016
+ function isSessionError(error) {
1017
+ return error instanceof AgentProtocolError && FATAL_SESSION_CODES.has(error.code);
1018
+ }
1019
+ function sameIdentityKeys(left, right) {
1020
+ return left.signingPublicKey === right.signingPublicKey
1021
+ && left.signingSecretKey === right.signingSecretKey
1022
+ && left.encryptionPublicKey === right.encryptionPublicKey
1023
+ && left.encryptionSecretKey === right.encryptionSecretKey;
1024
+ }
1025
+ function deterministicUuid(value) {
1026
+ const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
1027
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1028
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1029
+ const hex = bytes.toString("hex");
1030
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1031
+ }
422
1032
  async function attachmentInputFromFile(input) {
423
1033
  const path = resolve(input.path);
424
1034
  return {
@@ -442,12 +1052,12 @@ async function responseError(response) {
442
1052
  try {
443
1053
  const body = await response.json();
444
1054
  if (body.error)
445
- return new Error(`${body.error.code ?? response.status}: ${body.error.message ?? "request failed"}`);
1055
+ return new AgentProtocolError(body.error.code ?? `HTTP_${response.status}`, body.error.message ?? "request failed");
446
1056
  }
447
1057
  catch {
448
1058
  // Keep the HTTP fallback for non-JSON proxy responses.
449
1059
  }
450
- return new Error(`HTTP ${response.status}`);
1060
+ return new AgentProtocolError(`HTTP_${response.status}`, `HTTP ${response.status}`);
451
1061
  }
452
1062
  function reconnectDelay(attempt) {
453
1063
  const exponential = Math.min(30_000, 500 * (2 ** Math.min(attempt, 6)));