@amaster.ai/dsh-a2a 0.1.34 → 0.1.35

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/lib/content.js CHANGED
@@ -16,13 +16,17 @@
16
16
  * used: they take base64 wire uploads and throw on refusal — this path holds
17
17
  * raw bytes and must degrade, never fail.
18
18
  */
19
+ import { lookup as dnsLookup } from 'node:dns/promises';
19
20
  import { mkdir, writeFile } from 'node:fs/promises';
21
+ import { request as httpRequest } from 'node:http';
22
+ import { request as httpsRequest } from 'node:https';
23
+ import { BlockList, isIP } from 'node:net';
20
24
  import os from 'node:os';
21
25
  import path from 'node:path';
22
26
  /** Cap on one url part download. */
23
27
  const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
24
28
  const DOWNLOAD_TIMEOUT_MS = 30_000;
25
- export async function buildMessageContent(parts, attachments, dir, materialization) {
29
+ export async function buildMessageContent(parts, attachments, dir, materialization, downloads = {}) {
26
30
  const content = [];
27
31
  for (const part of parts) {
28
32
  const c = part.content;
@@ -38,7 +42,7 @@ export async function buildMessageContent(parts, attachments, dir, materializati
38
42
  break;
39
43
  case 'raw':
40
44
  case 'url':
41
- content.push(await fileContent(c, part, attachments, dir, materialization));
45
+ content.push(await fileContent(c, part, attachments, dir, materialization, downloads));
42
46
  break;
43
47
  default:
44
48
  // A part kind this SDK version predates: keep it visible, never drop.
@@ -59,17 +63,18 @@ export function uploadsDir(root) {
59
63
  /** Log and degrade one file-part failure to a metadata note. */
60
64
  function failNote(part, mediaType, uri, label, error) {
61
65
  console.error(`[dsh-a2a] ${label}:`, error);
62
- return note(part, mediaType, uri, error instanceof Error ? error.message : String(error));
66
+ const text = error instanceof Error ? error.message : String(error);
67
+ return note(part, mediaType, uri, uri ? text.replaceAll(uri, redactedUri(uri) ?? '[redacted URL]') : text);
63
68
  }
64
69
  /** Resolve one file part to an attachment block or a workspace file. */
65
- async function fileContent(c, part, attachments, dir, materialization) {
70
+ async function fileContent(c, part, attachments, dir, materialization, downloads) {
66
71
  let bytes;
67
72
  let mediaType = part.mediaType;
68
73
  let uri;
69
74
  try {
70
75
  if (c.$case === 'url') {
71
76
  uri = c.value;
72
- const got = await download(uri);
77
+ const got = await download(uri, downloads);
73
78
  bytes = got.bytes;
74
79
  mediaType ||= got.mediaType ?? '';
75
80
  }
@@ -171,6 +176,7 @@ function sanitizeUploadFileName(name) {
171
176
  }
172
177
  /** Tool-readable path stand-in for a file part. */
173
178
  function documentBlock(uri, mediaType, bytes, filePath, originalName) {
179
+ const displayUri = uri ? redactedUri(uri) : undefined;
174
180
  const attrs = [
175
181
  // The basename of the final path, so name= always matches the file on
176
182
  // disk — including collision suffixes.
@@ -178,7 +184,7 @@ function documentBlock(uri, mediaType, bytes, filePath, originalName) {
178
184
  originalName &&
179
185
  originalName !== path.win32.basename(filePath) &&
180
186
  `originalName="${attr(originalName)}"`,
181
- uri && `uri="${attr(uri)}"`,
187
+ displayUri && `uri="${attr(displayUri)}"`,
182
188
  mediaType && `type="${attr(mediaType)}"`,
183
189
  `size="${bytes}"`,
184
190
  `path="${attr(filePath)}"`,
@@ -191,10 +197,11 @@ function documentBlock(uri, mediaType, bytes, filePath, originalName) {
191
197
  };
192
198
  }
193
199
  function note(part, mediaType, uri, reason) {
200
+ const displayUri = uri ? redactedUri(uri) : undefined;
194
201
  const desc = [
195
202
  part.filename && `"${part.filename}"`,
196
203
  mediaType && `(${mediaType})`,
197
- uri && `at ${uri}`,
204
+ displayUri && `at ${displayUri}`,
198
205
  ]
199
206
  .filter(Boolean)
200
207
  .join(' ');
@@ -213,31 +220,157 @@ function dataText(value) {
213
220
  function attr(value) {
214
221
  return value.replace(/["\r\n<>]/g, ' ');
215
222
  }
216
- /** Fetch one url part's bytes over http(s), bounded in size and time. */
217
- async function download(uri) {
218
- const scheme = new URL(uri).protocol;
219
- if (scheme !== 'http:' && scheme !== 'https:') {
220
- throw new Error(`unsupported scheme "${scheme}"`);
221
- }
222
- const response = await fetch(uri, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
223
- if (!response.ok)
224
- throw new Error(`HTTP ${response.status}`);
225
- const declared = Number(response.headers.get('content-length'));
226
- if (declared > MAX_DOWNLOAD_BYTES) {
227
- throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
228
- }
229
- const chunks = [];
230
- let total = 0;
231
- if (response.body) {
232
- // Throwing out of the loop cancels the stream via the iterator's return.
233
- for await (const chunk of response.body) {
234
- total += chunk.byteLength;
235
- if (total > MAX_DOWNLOAD_BYTES) {
223
+ /** Display-only URL: credentials, query and fragment never enter prompts or notes. */
224
+ function redactedUri(uri) {
225
+ try {
226
+ const parsed = new URL(uri);
227
+ parsed.username = '';
228
+ parsed.password = '';
229
+ parsed.search = '';
230
+ parsed.hash = '';
231
+ return parsed.toString();
232
+ }
233
+ catch {
234
+ return undefined;
235
+ }
236
+ }
237
+ const nonPublic = new BlockList();
238
+ for (const [subnet, prefix] of [
239
+ ['0.0.0.0', 8],
240
+ ['10.0.0.0', 8],
241
+ ['100.64.0.0', 10],
242
+ ['127.0.0.0', 8],
243
+ ['169.254.0.0', 16],
244
+ ['172.16.0.0', 12],
245
+ ['192.0.0.0', 24],
246
+ ['192.0.2.0', 24],
247
+ ['192.168.0.0', 16],
248
+ ['198.18.0.0', 15],
249
+ ['198.51.100.0', 24],
250
+ ['203.0.113.0', 24],
251
+ ['224.0.0.0', 4],
252
+ ['240.0.0.0', 4],
253
+ ])
254
+ nonPublic.addSubnet(subnet, prefix, 'ipv4');
255
+ for (const [subnet, prefix] of [
256
+ ['::', 128],
257
+ ['::1', 128],
258
+ ['fc00::', 7],
259
+ ['fe80::', 10],
260
+ ['ff00::', 8],
261
+ ['2001:db8::', 32],
262
+ ])
263
+ nonPublic.addSubnet(subnet, prefix, 'ipv6');
264
+ function assertPublicAddress(address) {
265
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1];
266
+ const value = mapped ?? address;
267
+ const family = isIP(value);
268
+ if (!family || nonPublic.check(value, family === 6 ? 'ipv6' : 'ipv4')) {
269
+ throw new Error(`download targets a private or non-public address: ${address}`);
270
+ }
271
+ }
272
+ async function safeAddresses(hostname, allowedHosts, allowPrivate) {
273
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
274
+ if (allowPrivate || allowedHosts.has(host))
275
+ return [];
276
+ const addresses = isIP(host)
277
+ ? [{ address: host, family: isIP(host) }]
278
+ : await dnsLookup(host, { all: true, verbatim: true });
279
+ if (!addresses.length)
280
+ throw new Error(`download host has no address: ${host}`);
281
+ for (const { address } of addresses)
282
+ assertPublicAddress(address);
283
+ return addresses;
284
+ }
285
+ /** Fetch one url part's bytes over http(s), bounded in size, redirects, address and time. */
286
+ async function download(uri, options) {
287
+ const allowedHosts = new Set((options.allowedHosts ?? []).map((host) => host.replace(/^\[|\]$/g, '').toLowerCase()));
288
+ const controller = new AbortController();
289
+ const timeout = setTimeout(() => controller.abort(new Error('download timed out')), DOWNLOAD_TIMEOUT_MS);
290
+ timeout.unref();
291
+ try {
292
+ let current = new URL(uri);
293
+ for (let redirect = 0;; redirect++) {
294
+ if (current.protocol !== 'http:' && current.protocol !== 'https:') {
295
+ throw new Error(`unsupported scheme "${current.protocol}"`);
296
+ }
297
+ await safeAddresses(current.hostname, allowedHosts, options.allowPrivate === true);
298
+ const response = await new Promise((resolve, reject) => {
299
+ const request = (current.protocol === 'http:' ? httpRequest : httpsRequest)(current, {
300
+ signal: controller.signal,
301
+ lookup: (hostname, lookupOptions, callback) => {
302
+ const allowed = options.allowPrivate === true ||
303
+ allowedHosts.has(hostname.replace(/^\[|\]$/g, '').toLowerCase());
304
+ const resolving = allowed
305
+ ? dnsLookup(hostname, { all: lookupOptions.all ?? false }).then((entry) => Array.isArray(entry) ? entry : [entry])
306
+ : safeAddresses(hostname, allowedHosts, false);
307
+ void resolving.then((addresses) => {
308
+ if (lookupOptions.all)
309
+ return callback(null, addresses);
310
+ const { address, family } = addresses[0];
311
+ callback(null, address, family);
312
+ return addresses;
313
+ }, (error) => callback(error, '', 0));
314
+ },
315
+ }, resolve);
316
+ request.on('socket', (socket) => {
317
+ socket.once('connect', () => {
318
+ if (options.allowPrivate === true)
319
+ return;
320
+ if (allowedHosts.has(current.hostname.replace(/^\[|\]$/g, '').toLowerCase()))
321
+ return;
322
+ try {
323
+ assertPublicAddress(socket.remoteAddress ?? '');
324
+ }
325
+ catch (error) {
326
+ request.destroy(error);
327
+ }
328
+ });
329
+ });
330
+ request.on('error', reject);
331
+ request.end();
332
+ });
333
+ const status = response.statusCode ?? 0;
334
+ const location = response.headers.location;
335
+ if ([301, 302, 303, 307, 308].includes(status) && location) {
336
+ response.resume();
337
+ if (redirect >= 5)
338
+ throw new Error('too many download redirects');
339
+ current = new URL(location, current);
340
+ continue;
341
+ }
342
+ if (status < 200 || status >= 300) {
343
+ response.resume();
344
+ throw new Error(`HTTP ${status}`);
345
+ }
346
+ const declared = Number(response.headers['content-length']);
347
+ if (declared > MAX_DOWNLOAD_BYTES) {
348
+ response.destroy();
236
349
  throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
237
350
  }
238
- chunks.push(chunk);
351
+ const chunks = [];
352
+ let total = 0;
353
+ try {
354
+ for await (const chunk of response) {
355
+ total += chunk.byteLength;
356
+ if (total > MAX_DOWNLOAD_BYTES) {
357
+ throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
358
+ }
359
+ chunks.push(chunk);
360
+ }
361
+ }
362
+ catch (error) {
363
+ response.destroy();
364
+ throw error;
365
+ }
366
+ const contentType = response.headers['content-type'];
367
+ const mediaType = (Array.isArray(contentType) ? contentType[0] : contentType)
368
+ ?.split(';')[0]
369
+ .trim();
370
+ return { bytes: Buffer.concat(chunks, total), mediaType: mediaType || undefined };
239
371
  }
240
372
  }
241
- const mediaType = response.headers.get('content-type')?.split(';')[0].trim() || undefined;
242
- return { bytes: Buffer.concat(chunks, total), mediaType };
373
+ finally {
374
+ clearTimeout(timeout);
375
+ }
243
376
  }
package/lib/executor.js CHANGED
@@ -19,11 +19,14 @@
19
19
  */
20
20
  import { TaskState } from '@a2a-js/sdk';
21
21
  import { AgentEvent, STATE_HEADERS_KEY } from '@a2a-js/sdk/server';
22
+ import { AdmissionGate } from './admission.js';
22
23
  import { ApprovalReplyError } from './approval.js';
23
24
  import { agentTextMessage, terminalStatusUpdate } from './translator.js';
24
25
  export class DshAgentExecutor {
25
26
  bridge;
26
27
  suppressTask;
28
+ admissions = new AdmissionGate();
29
+ admissionFailures = new Map();
27
30
  constructor(bridge, suppressTask) {
28
31
  this.bridge = bridge;
29
32
  this.suppressTask = suppressTask;
@@ -35,9 +38,35 @@ export class DshAgentExecutor {
35
38
  if (this.bridge.isClearing(requestContext.contextId)) {
36
39
  this.suppressTask?.(requestContext.taskId);
37
40
  }
38
- return this.bridge.trackExecution(requestContext.contextId, () => this.executeInner(requestContext, eventBus));
41
+ const contextId = requestContext.contextId;
42
+ const failureVersion = this.admissionFailures.get(contextId) ?? 0;
43
+ const admission = this.admissions.acquire(contextId);
44
+ let accepted = false;
45
+ const accept = () => {
46
+ if (accepted)
47
+ return;
48
+ accepted = true;
49
+ admission.release(() => this.admissionFailures.delete(contextId));
50
+ };
51
+ try {
52
+ return await this.bridge.trackExecution(contextId, async () => {
53
+ await admission.ready;
54
+ if ((this.admissionFailures.get(contextId) ?? 0) !== failureVersion) {
55
+ eventBus.publish(taskEvent(requestContext.taskId, contextId, TaskState.TASK_STATE_FAILED));
56
+ eventBus.publish(terminalStatusUpdate(requestContext.taskId, contextId, 'failed', 'Preceding queued turn failed.'));
57
+ eventBus.finished();
58
+ return;
59
+ }
60
+ await this.executeInner(requestContext, eventBus, accept, () => {
61
+ this.admissionFailures.set(contextId, (this.admissionFailures.get(contextId) ?? 0) + 1);
62
+ });
63
+ });
64
+ }
65
+ finally {
66
+ accept();
67
+ }
39
68
  }
40
- async executeInner(requestContext, eventBus) {
69
+ async executeInner(requestContext, eventBus, accept, fail) {
41
70
  const { userMessage, taskId, contextId } = requestContext;
42
71
  let anchored = false;
43
72
  try {
@@ -49,6 +78,7 @@ export class DshAgentExecutor {
49
78
  }
50
79
  finally {
51
80
  approval.resume();
81
+ accept();
52
82
  }
53
83
  await this.bridge.waitForTurn(approval.entry);
54
84
  return;
@@ -65,11 +95,16 @@ export class DshAgentExecutor {
65
95
  taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
66
96
  anchored = true;
67
97
  const requestHeaders = requestContext.context.state.get(STATE_HEADERS_KEY);
68
- await this.bridge.runTurn(entry, content, eventBus, taskId, userMessage, requestHeaders);
98
+ const turn = this.bridge.runTurn(entry, content, eventBus, taskId, userMessage, requestHeaders);
99
+ // runTurn registers synchronously before its first await. The next
100
+ // request may prepare now; the bridge FIFO still executes turns in order.
101
+ accept();
102
+ await turn;
69
103
  }
70
104
  catch (error) {
71
105
  if (error instanceof ApprovalReplyError)
72
106
  return;
107
+ fail();
73
108
  if (this.bridge.isClearing(contextId))
74
109
  this.suppressTask?.(taskId);
75
110
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,117 @@
1
+ /** JSON-RPC method extension; standard task methods stay with the official SDK. */
2
+ import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER, Extensions, HTTP_EXTENSION_HEADER, Message, } from '@a2a-js/sdk';
3
+ import { ExtensionSupportRequiredError, RequestMalformedError, toJsonRpcError, } from '@a2a-js/sdk/errors';
4
+ import { validateVersion } from '@a2a-js/sdk/server';
5
+ import { INBOX_EXTENSION, validateInboxPage } from './inbox.js';
6
+ export function inboxRpcHandler(inbox, card) {
7
+ return async (req, res, next) => {
8
+ const body = req.body;
9
+ if (typeof body?.method !== 'string' || !body.method.startsWith('dsh/inbox/'))
10
+ return next();
11
+ try {
12
+ if (body.jsonrpc !== '2.0' || !['string', 'number'].includes(typeof body.id))
13
+ throw new RequestMalformedError('A JSON-RPC 2.0 request id is required.');
14
+ validateVersion(req.get(A2A_VERSION_HEADER) || A2A_PROTOCOL_VERSION, card, 'JSONRPC');
15
+ if (!Extensions.parseServiceParameter(req.get(HTTP_EXTENSION_HEADER)).includes(INBOX_EXTENSION))
16
+ throw new ExtensionSupportRequiredError('Activate the native inbox extension.');
17
+ res.setHeader(HTTP_EXTENSION_HEADER, INBOX_EXTENSION);
18
+ const params = body.params;
19
+ if (!params ||
20
+ typeof params !== 'object' ||
21
+ Array.isArray(params) ||
22
+ typeof params.contextId !== 'string' ||
23
+ !params.contextId.trim() ||
24
+ (params.tenant !== undefined && params.tenant !== ''))
25
+ throw new RequestMalformedError('A contextId and the default tenant are required.');
26
+ const method = body.method.slice('dsh/inbox/'.length);
27
+ const page = { pageSize: params.pageSize, pageToken: params.pageToken };
28
+ validateInboxPage(page);
29
+ if (method !== 'get' && page.pageToken)
30
+ throw new RequestMalformedError('pageToken is only supported by inbox get.');
31
+ if (['update', 'move', 'remove', 'steer'].includes(method) &&
32
+ (typeof params.id !== 'string' || !params.id))
33
+ throw new RequestMalformedError('A native inbox id is required.');
34
+ let result;
35
+ switch (method) {
36
+ case 'get':
37
+ result = await inbox.get(params.contextId, page);
38
+ break;
39
+ case 'update': {
40
+ if (!params.message ||
41
+ typeof params.message !== 'object' ||
42
+ Array.isArray(params.message))
43
+ throw new RequestMalformedError('A user Message is required.');
44
+ let message;
45
+ try {
46
+ message = Message.fromJSON(params.message);
47
+ }
48
+ catch {
49
+ throw new RequestMalformedError('Invalid A2A Message.');
50
+ }
51
+ result = await inbox.update(params.contextId, params.id, message, page);
52
+ break;
53
+ }
54
+ case 'move':
55
+ result = await inbox.move(params.contextId, params.id, params.index, page);
56
+ break;
57
+ case 'remove':
58
+ result = await inbox.remove(params.contextId, params.id, page);
59
+ break;
60
+ case 'clear':
61
+ result = await inbox.clearPending(params.contextId, page);
62
+ break;
63
+ case 'execute': {
64
+ if (params.id !== undefined && (typeof params.id !== 'string' || !params.id))
65
+ throw new RequestMalformedError('id must be a native inbox id.');
66
+ result = await inbox.executePending(params.contextId, params.id, page);
67
+ break;
68
+ }
69
+ case 'steer': {
70
+ if (typeof params.taskId !== 'string' || !params.taskId)
71
+ throw new RequestMalformedError('An active taskId is required.');
72
+ await inbox.promote(params.contextId, params.id, params.taskId);
73
+ result = await inbox.get(params.contextId, page);
74
+ break;
75
+ }
76
+ case 'subscribe': {
77
+ const agent = await inbox.resolve(params.contextId);
78
+ if (res.destroyed)
79
+ return;
80
+ res.setHeader('Content-Type', 'text/event-stream');
81
+ res.setHeader('Cache-Control', 'no-cache');
82
+ const dispose = inbox.changes.watch(agent.session, (change) => {
83
+ if (res.destroyed || res.writableEnded)
84
+ return;
85
+ res.flushHeaders();
86
+ if (!res.write(`data: ${JSON.stringify({ jsonrpc: '2.0', id: body.id, result: change })}\n\n`))
87
+ res.destroy();
88
+ }, () => res.end());
89
+ res.once('close', dispose);
90
+ try {
91
+ await inbox.changes.checkpoint(agent.session);
92
+ }
93
+ catch (error) {
94
+ res.off('close', dispose);
95
+ dispose();
96
+ throw error;
97
+ }
98
+ return;
99
+ }
100
+ default:
101
+ return next();
102
+ }
103
+ res.json({ jsonrpc: '2.0', id: body.id, result });
104
+ }
105
+ catch (error) {
106
+ if (res.destroyed || res.writableEnded)
107
+ return;
108
+ const response = { jsonrpc: '2.0', id: body?.id ?? null, error: toJsonRpcError(error) };
109
+ if (res.headersSent)
110
+ res.end(`data: ${JSON.stringify(response)}\n\n`);
111
+ else {
112
+ res.setHeader('Content-Type', 'application/json');
113
+ res.json(response);
114
+ }
115
+ }
116
+ };
117
+ }
@@ -0,0 +1,116 @@
1
+ import { UnsupportedOperationError } from '@a2a-js/sdk/errors';
2
+ /** Invalidation only: DSH owns the queue, sequence and durability barrier. */
3
+ export class InboxChanges {
4
+ ctx;
5
+ states = new WeakMap();
6
+ listeners = new Map();
7
+ disposed = new WeakSet();
8
+ detach;
9
+ stopped = false;
10
+ constructor(ctx, managed, enabled = false) {
11
+ this.ctx = ctx;
12
+ this.detach = [
13
+ ctx.on('session/event', (session, event) => {
14
+ if (this.stopped ||
15
+ this.disposed.has(session) ||
16
+ event.type !== 'agent/inbox/spliced' ||
17
+ !managed(session.id) ||
18
+ (!enabled && !this.listeners.has(session)))
19
+ return;
20
+ const pending = this.states.get(session);
21
+ if (pending?.job) {
22
+ pending.requested = Math.max(pending.requested, session.seq - 1);
23
+ return;
24
+ }
25
+ void this.checkpoint(session).catch((error) => {
26
+ if (!this.stopped)
27
+ console.error('[dsh-a2a] inbox checkpoint notification failed:', error);
28
+ });
29
+ }),
30
+ ctx.on('session/disposed', (session) => this.closeSession(session)),
31
+ ];
32
+ ctx.effect(() => () => this.close());
33
+ }
34
+ watch(session, listener, closed) {
35
+ if (this.stopped || this.disposed.has(session))
36
+ throw new UnsupportedOperationError('Inbox notifications are closed.');
37
+ let listeners = this.listeners.get(session);
38
+ if (!listeners) {
39
+ listeners = new Map();
40
+ this.listeners.set(session, listeners);
41
+ }
42
+ listeners.set(listener, closed);
43
+ const dispose = () => {
44
+ listeners.delete(listener);
45
+ if (!listeners.size && this.listeners.get(session) === listeners)
46
+ this.listeners.delete(session);
47
+ };
48
+ return dispose;
49
+ }
50
+ checkpoint(session) {
51
+ if (this.stopped || this.disposed.has(session))
52
+ return Promise.reject(new UnsupportedOperationError('Inbox notifications are closed.'));
53
+ let state = this.states.get(session);
54
+ if (!state) {
55
+ state = { requested: -1 };
56
+ this.states.set(session, state);
57
+ }
58
+ state.requested = Math.max(state.requested, session.seq - 1);
59
+ const pending = state;
60
+ if (!pending.job)
61
+ pending.job = Promise.resolve().then(async () => {
62
+ try {
63
+ let version;
64
+ do {
65
+ if (this.stopped || this.disposed.has(session))
66
+ throw new UnsupportedOperationError('Inbox notifications are closed.');
67
+ version = pending.requested;
68
+ const sessions = this.ctx.get('sessions');
69
+ if (!sessions || !(await sessions.flush(session)))
70
+ throw new UnsupportedOperationError('Inbox notifications require a successful DSH durability checkpoint.');
71
+ if (this.stopped || this.disposed.has(session))
72
+ throw new UnsupportedOperationError('Inbox notifications are closed.');
73
+ const change = { contextId: session.id, version };
74
+ for (const listener of this.listeners.get(session)?.keys() ?? []) {
75
+ try {
76
+ listener(change);
77
+ }
78
+ catch (error) {
79
+ console.error('[dsh-a2a] inbox notification listener failed:', error);
80
+ }
81
+ }
82
+ await this.ctx.parallel('a2a/inbox-checkpointed', change).catch((error) => {
83
+ console.error('[dsh-a2a] inbox checkpoint observer failed:', error);
84
+ });
85
+ } while (pending.requested > version);
86
+ return { contextId: session.id, version };
87
+ }
88
+ finally {
89
+ pending.job = undefined;
90
+ }
91
+ });
92
+ return pending.job;
93
+ }
94
+ close() {
95
+ if (this.stopped)
96
+ return;
97
+ this.stopped = true;
98
+ for (const detach of this.detach)
99
+ detach();
100
+ for (const session of this.listeners.keys())
101
+ this.closeSession(session);
102
+ }
103
+ closeSession(session) {
104
+ this.disposed.add(session);
105
+ const listeners = this.listeners.get(session);
106
+ this.listeners.delete(session);
107
+ for (const close of listeners?.values() ?? []) {
108
+ try {
109
+ close();
110
+ }
111
+ catch (error) {
112
+ console.error('[dsh-a2a] inbox subscription cleanup failed:', error);
113
+ }
114
+ }
115
+ }
116
+ }