@ai_mind/dsh-a2a 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  把本机 DeepSeek Harness 智能体暴露为 **A2A(Agent2Agent)v1.0 服务端**的生产级插件。
4
4
 
5
- 协议实现基于官方 [`@a2a-js/sdk` 1.3.0](https://www.npmjs.com/package/@a2a-js/sdk)(A2A Protocol Specification v1.0 的官方 TypeScript SDK),本插件负责把它挂到 Harness 的 Web 服务器上,并把 A2A 任务桥接到真实的 Harness Agent:每个 A2A `contextId` 对应一个可复用的 Harness 会话,任务取消、超时、并发上限、沙箱与审批策略都由插件统一管理。
5
+ 协议实现基于官方 [`@a2a-js/sdk` 1.3.0](https://www.npmjs.com/package/@a2a-js/sdk)(A2A Protocol Specification v1.0 的官方 TypeScript SDK)。本插件默认复用 Harness Web 的监听端口,也可以配置 `server.port` 让 A2A 在独立端口监听,并把 A2A 任务桥接到真实的 Harness Agent:每个 A2A `contextId` 对应一个可复用的 Harness 会话,任务取消、超时、并发上限、沙箱与审批策略都由插件统一管理。
6
6
 
7
7
  - 规范:[A2A Protocol v1.0.0](https://a2a-protocol.org/v1.0.0/specification/)
8
8
  - 绑定:**JSON-RPC 2.0** + **HTTP+JSON/REST**(gRPC 未暴露,见[已知限制](#已知限制))
@@ -73,13 +73,13 @@
73
73
  3. 验证 Agent Card:
74
74
 
75
75
  ```bash
76
- curl -s http://127.0.0.1:57133/.well-known/agent-card.json | jq .
76
+ curl -s http://127.0.0.1:41241/.well-known/agent-card.json | jq .
77
77
  ```
78
78
 
79
79
  4. 发一条消息(注意必须带 `A2A-Version: 1.0`):
80
80
 
81
81
  ```bash
82
- curl -s http://127.0.0.1:57133/a2a \
82
+ curl -s http://127.0.0.1:41241/a2a \
83
83
  -H 'content-type: application/json' \
84
84
  -H 'a2a-version: 1.0' \
85
85
  -H "authorization: Bearer $A2A_AUTH_TOKEN" \
@@ -100,7 +100,7 @@
100
100
  5. 流式调用:
101
101
 
102
102
  ```bash
103
- curl -N http://127.0.0.1:57133/a2a \
103
+ curl -N http://127.0.0.1:41241/a2a \
104
104
  -H 'content-type: application/json' \
105
105
  -H 'a2a-version: 1.0' \
106
106
  -d '{"jsonrpc":"2.0","id":2,"method":"SendStreamingMessage","params":{"message":{"messageId":"m-2","role":"ROLE_USER","parts":[{"text":"列出当前目录的文件"}]}}}'
@@ -118,7 +118,7 @@
118
118
  2. **配置表单**:直接编辑名称、描述、对外地址、挂载路径、流式/推送/扩展卡片、鉴权模式与令牌环境变量、访问令牌(写入后不再回显)、Agent 预设/模型/工作目录/沙箱/审批/并发/超时,以及请求体与任务保留限额。
119
119
  3. **保存**:写入 profile 的 `cordis.patch.yml`(`a2a-server` 行),校验通过后插件自动重载;修改挂载路径后刷新页面即可。
120
120
 
121
- 配置接口 `GET/PUT <mountPath>/panel/config` 使用 **Harness Web 连接凭据**(不是 A2A token)鉴权:只有能打开 Settings 的已登录浏览器可以读写,远程 A2A 客户端即使知道路径也会收到 401。令牌字段是只写字段,GET 只返回“是否已配置”。
121
+ 配置接口 `GET/PUT <mountPath>/panel/config` 始终位于 DSH Web 的 origin(独立端口模式下也不在 A2A 端口上),使用 **Harness Web 连接凭据**(不是 A2A token)鉴权:只有能打开 Settings 的已登录浏览器可以读写,远程 A2A 客户端即使知道路径也会收到 401。令牌字段是只写字段,GET 只返回“是否已配置”。
122
122
 
123
123
  `lib/client-discovery.js` 会在插件挂载时清掉 DSH client 模块扫描器对“这个包没有浏览器半”的进程级缓存并触发一次重新扫描,因此从旧版本热升级后无需重启即可看到面板;全新启动时该逻辑自动跳过。
124
124
 
@@ -129,13 +129,13 @@ A2A 客户端必须发送 `A2A-Version: 1.0`,并带 `Content-Type: application
129
129
  ### 1. 发现 Agent Card
130
130
 
131
131
  ```bash
132
- curl -s http://127.0.0.1:57133/.well-known/agent-card.json | jq .
132
+ curl -s http://127.0.0.1:41241/.well-known/agent-card.json | jq .
133
133
  ```
134
134
 
135
135
  ### 2. JSON-RPC 发送消息(阻塞)
136
136
 
137
137
  ```bash
138
- curl -s http://127.0.0.1:57133/a2a \
138
+ curl -s http://127.0.0.1:41241/a2a \
139
139
  -H 'content-type: application/json' \
140
140
  -H 'a2a-version: 1.0' \
141
141
  -d '{
@@ -155,7 +155,7 @@ curl -s http://127.0.0.1:57133/a2a \
155
155
  ### 3. 流式(SSE)
156
156
 
157
157
  ```bash
158
- curl -N http://127.0.0.1:57133/a2a \
158
+ curl -N http://127.0.0.1:41241/a2a \
159
159
  -H 'content-type: application/json' \
160
160
  -H 'a2a-version: 1.0' \
161
161
  -d '{"jsonrpc":"2.0","id":2,"method":"SendStreamingMessage","params":{"message":{"messageId":"m-2","role":"ROLE_USER","parts":[{"text":"列出当前目录的文件"}]}}}'
@@ -170,7 +170,7 @@ npm install @a2a-js/sdk
170
170
  ```js
171
171
  import { ClientFactory, Role } from '@a2a-js/sdk/client'
172
172
 
173
- const client = await new ClientFactory().createFromUrl('http://127.0.0.1:57133')
173
+ const client = await new ClientFactory().createFromUrl('http://127.0.0.1:41241')
174
174
  const result = await client.sendMessage({
175
175
  message: {
176
176
  messageId: crypto.randomUUID(),
@@ -186,7 +186,7 @@ console.log(result)
186
186
  ### 5. REST
187
187
 
188
188
  ```bash
189
- curl -s http://127.0.0.1:57133/a2a/rest/message:send \
189
+ curl -s http://127.0.0.1:41241/a2a/rest/message:send \
190
190
  -H 'content-type: application/a2a+json' \
191
191
  -H 'a2a-version: 1.0' \
192
192
  -d '{"message":{"messageId":"m-3","role":"ROLE_USER","parts":[{"text":"你好"}]}}' | jq .
@@ -228,6 +228,16 @@ JSON-RPC 方法名:`SendMessage`、`SendStreamingMessage`、`GetTask`、`ListT
228
228
 
229
229
  凭据来源(可组合):`auth.clients[{id,token}]`、`auth.tokens[]`、`auth.token`、`auth.tokenEnv`(环境变量,支持逗号/换行分隔多个)。启用鉴权后 Agent Card 会自动声明 `securitySchemes` 与 `securityRequirements`。
230
230
 
231
+ 内联令牌可以直接写在插件配置里(设置页的「A2A 访问令牌(内联)」字段等价):
232
+
233
+ ```yaml
234
+ auth:
235
+ mode: bearer
236
+ token: 'your-long-random-token'
237
+ ```
238
+
239
+ > 在设置页输入令牌时,如果 `auth.mode` 还是 `none`,保存会自动切换为 `bearer`。令牌不会回显;它会以明文保存在 profile 的 `cordis.patch.yml`(权限 600),生产环境建议改用 `auth.tokenEnv` 或 `auth.clients`。
240
+
231
241
  **每个凭据是一个独立的租户边界**:任务与 Push 配置按凭据 id 隔离,客户端 A 看不到客户端 B 的任务。未鉴权时所有 loopback 调用方共享 `anonymous` 作用域。
232
242
 
233
243
  ### 默认安全姿态
@@ -285,6 +295,27 @@ agent:
285
295
 
286
296
  ---
287
297
 
298
+ ## 独立端口(不与 DSH 复用)
299
+
300
+ 插件配置的默认值是 `server.port: null`:A2A 直接复用 DSH Web 的端口,Agent Card、JSON-RPC、REST 和设置面板共享同一个 origin。本仓库的 bundle patch 为了方便独立部署,默认设为 `127.0.0.1:41241`。需要显式控制时:
301
+
302
+ ```yaml
303
+ server:
304
+ host: 127.0.0.1
305
+ port: 41241
306
+ ```
307
+
308
+ 此时:
309
+
310
+ - A2A 在 `http://127.0.0.1:41241/a2a` 单独监听(REST 为 `/a2a/rest`),Agent Card 和 `health` 也在该端口;
311
+ - DSH Web 端口只保留卡片镜像 `/.well-known/agent-card.json`(供设置页发现)和 `<mountPath>/panel` 配置面板(仍使用 GUI 登录凭据,不暴露在 A2A 端口上);
312
+ - Agent Card 的 `supportedInterfaces` 使用 `http://host:port`;如果前面有反向代理/TLS 终止,设置 `server.publicUrl` 为外部地址;
313
+ - 独立监听本身是 HTTP;公网部署时由反向代理终止 TLS。
314
+
315
+ 本仓库的 `cordis.patch.yml` 默认使用 `127.0.0.1:41241`;修改 `server.port`(或在设置页的“监听端口”保存)后插件会重载并切换监听。若端口被占用,设置页会显示监听错误。
316
+
317
+ ---
318
+
288
319
  ## 配置参考
289
320
 
290
321
  完整默认值见 [`lib/config.js`](lib/config.js)。所有字段均可在 bundle 行 `config` 中覆盖;未知字段会令插件激活失败(避免拼写错误静默生效)。
@@ -295,7 +326,9 @@ enabled: true
295
326
  server:
296
327
  mountPath: /a2a
297
328
  cardPath: /.well-known/agent-card.json
298
- publicUrl: null # 对外可达的 origin;不填则由 WebServer 的绑定地址推导
329
+ publicUrl: null # 对外可达的 origin;独立端口时默认 http://host:port
330
+ host: 127.0.0.1 # 独立监听的绑定地址(仅 server.port 非 null 时生效)
331
+ port: null # null = 复用 DSH Web 端口;1-65535 = 独立监听
299
332
  trustProxy: false # 反向代理后设为 true,以使用 X-Forwarded-For
300
333
  streaming: true
301
334
  pushNotifications: true
package/client.js CHANGED
@@ -40,6 +40,16 @@ window.__ModuleLoader__.load({
40
40
  authOn: '已启用',
41
41
  version: '协议版本',
42
42
  runtime: '运行状态',
43
+ serverStatus: '监听方式',
44
+ serverMode: '模式',
45
+ serverModeShared: '复用 DSH 端口',
46
+ serverModeStandalone: '独立端口',
47
+ serverAddress: '地址',
48
+ serverListening: '监听中',
49
+ serverListeningYes: '是',
50
+ serverListeningNo: '否',
51
+ serverError: '监听错误',
52
+ serverNotListening: '独立端口未监听成功:请检查端口是否被占用,或修改 server.port。',
43
53
  runtimeDenied: '运行状态需要 A2A 凭据,当前页面无权读取;这不影响其他信息。',
44
54
  tasks: '任务',
45
55
  contexts: '活跃上下文',
@@ -62,11 +72,11 @@ window.__ModuleLoader__.load({
62
72
  saveError: '保存失败',
63
73
  reload: '重新读取',
64
74
  dirty: '有未保存的修改',
65
- token: '访问令牌',
66
- tokenHint: '写回 profile YAML;推荐改用 auth.tokenEnv 从环境变量读取。',
67
- tokenPlaceholder: '留空保持不变',
75
+ token: 'A2A 访问令牌(内联)',
76
+ tokenHint: '保存后写入插件配置的 auth.token;如果鉴权模式为 none,会自动切换为 bearer。值不会回显。',
77
+ tokenPlaceholder: '输入新的访问令牌',
68
78
  clearToken: '清除已保存的内联令牌',
69
- tokenConfigured: '已配置内联令牌',
79
+ tokenConfigured: '已配置内联令牌(值不会回显)',
70
80
  tokenNotConfigured: '未配置内联令牌',
71
81
  tokenEnvConfigured: '环境变量已配置:{name}',
72
82
  openCard: '打开 Agent Card',
@@ -83,6 +93,8 @@ window.__ModuleLoader__.load({
83
93
  fieldCardDescription: '描述',
84
94
  fieldCardVersion: '版本',
85
95
  fieldServerPublicUrl: '对外地址',
96
+ fieldServerHost: '监听地址',
97
+ fieldServerPort: '监听端口',
86
98
  fieldServerMountPath: '挂载路径',
87
99
  fieldServerStreaming: '流式 SSE',
88
100
  fieldServerPushNotifications: '推送通知',
@@ -139,6 +151,16 @@ window.__ModuleLoader__.load({
139
151
  authOn: 'Required',
140
152
  version: 'Protocol version',
141
153
  runtime: 'Runtime',
154
+ serverStatus: 'Listener',
155
+ serverMode: 'Mode',
156
+ serverModeShared: 'Shared with DSH',
157
+ serverModeStandalone: 'Dedicated port',
158
+ serverAddress: 'Address',
159
+ serverListening: 'Listening',
160
+ serverListeningYes: 'yes',
161
+ serverListeningNo: 'no',
162
+ serverError: 'Listener error',
163
+ serverNotListening: 'The dedicated listener is not up. Check whether the port is taken or change server.port.',
142
164
  runtimeDenied: 'Runtime counters require A2A credentials; the panel cannot read them. Everything else is unaffected.',
143
165
  tasks: 'Tasks',
144
166
  contexts: 'Active contexts',
@@ -161,11 +183,11 @@ window.__ModuleLoader__.load({
161
183
  saveError: 'Save failed',
162
184
  reload: 'Reload',
163
185
  dirty: 'Unsaved changes',
164
- token: 'Access token',
165
- tokenHint: 'Stored in the profile YAML. Prefer auth.tokenEnv instead.',
166
- tokenPlaceholder: 'Leave empty to keep',
186
+ token: 'A2A access token (inline)',
187
+ tokenHint: 'Saved as auth.token in the plugin config. A mode of "none" switches to bearer automatically. The value is never echoed.',
188
+ tokenPlaceholder: 'Enter a new access token',
167
189
  clearToken: 'Clear the saved inline token',
168
- tokenConfigured: 'Inline token configured',
190
+ tokenConfigured: 'Inline token configured (value is never shown)',
169
191
  tokenNotConfigured: 'No inline token',
170
192
  tokenEnvConfigured: 'Environment variable: {name}',
171
193
  openCard: 'Open Agent Card',
@@ -182,6 +204,8 @@ window.__ModuleLoader__.load({
182
204
  fieldCardDescription: 'Description',
183
205
  fieldCardVersion: 'Version',
184
206
  fieldServerPublicUrl: 'Public URL',
207
+ fieldServerHost: 'Listen host',
208
+ fieldServerPort: 'Listen port',
185
209
  fieldServerMountPath: 'Mount path',
186
210
  fieldServerStreaming: 'Streaming SSE',
187
211
  fieldServerPushNotifications: 'Push notifications',
@@ -427,15 +451,14 @@ window.__ModuleLoader__.load({
427
451
  if (jsonrpc && typeof jsonrpc.url === 'string') {
428
452
  try {
429
453
  const url = new URL(jsonrpc.url, window.location.origin)
430
- const base = `${window.location.origin}${url.pathname.replace(/\/$/, '')}`
431
- try {
432
- const healthResponse = await fetch(`${base}/health`, { cache: 'no-store', signal: controller.signal })
433
- if (healthResponse.ok) health = await healthResponse.json()
434
- else denied = healthResponse.status === 401 || healthResponse.status === 403
435
- } catch {
436
- denied = true
437
- }
438
- panelPath = `${base}/panel/config`
454
+ const pathname = url.pathname.replace(/\/$/, '')
455
+ // The A2A port owns health; the panel stays on the GUI origin
456
+ // so the settings form reuses the browser's Harness session.
457
+ const endpointBase = `${url.origin}${pathname}`
458
+ const panelBase = `${window.location.origin}${pathname}`
459
+ // Prefer the same-origin panel: it carries the health snapshot
460
+ // and avoids a cross-origin read on the dedicated A2A port.
461
+ panelPath = `${panelBase}/panel/config`
439
462
  try {
440
463
  const panelResponse = await fetch(panelPath, { cache: 'no-store', signal: controller.signal, credentials: 'same-origin' })
441
464
  if (panelResponse.ok) panel = await panelResponse.json()
@@ -445,6 +468,17 @@ window.__ModuleLoader__.load({
445
468
  } catch {
446
469
  panelError = 'error'
447
470
  }
471
+ if (panel && panel.health) {
472
+ health = panel.health
473
+ } else {
474
+ try {
475
+ const healthResponse = await fetch(`${endpointBase}/health`, { cache: 'no-store', signal: controller.signal })
476
+ if (healthResponse.ok) health = await healthResponse.json()
477
+ else denied = healthResponse.status === 401 || healthResponse.status === 403
478
+ } catch {
479
+ /* standalone listener down: the panel status reports it */
480
+ }
481
+ }
448
482
  } catch {
449
483
  denied = true
450
484
  }
@@ -619,7 +653,7 @@ window.__ModuleLoader__.load({
619
653
  const secretBlock = panel
620
654
  ? h(
621
655
  'div',
622
- { className: 'a2aui-group' },
656
+ { className: 'a2aui-group', key: 'inline-token' },
623
657
  h('h5', { className: 'a2aui-group-title' }, t('token')),
624
658
  h(
625
659
  'div',
@@ -670,8 +704,8 @@ window.__ModuleLoader__.load({
670
704
  ? h(
671
705
  'div',
672
706
  { className: 'a2aui-form' },
673
- ...groups.map(([group, fields]) =>
674
- h(
707
+ ...groups.flatMap(([group, fields]) => {
708
+ const node = h(
675
709
  'div',
676
710
  { className: 'a2aui-group', key: group },
677
711
  h('h5', { className: 'a2aui-group-title' }, groupLabel(t, group)),
@@ -683,9 +717,9 @@ window.__ModuleLoader__.load({
683
717
  h('div', null, renderControl(field)),
684
718
  ),
685
719
  ),
686
- ),
687
- ),
688
- secretBlock,
720
+ )
721
+ return group === 'auth' ? [node, secretBlock] : [node]
722
+ }),
689
723
  h(
690
724
  'div',
691
725
  { className: 'a2aui-form-foot' },
@@ -761,6 +795,30 @@ window.__ModuleLoader__.load({
761
795
  ),
762
796
  )
763
797
 
798
+ const serverInfo = (state.panel && state.panel.server) || (health && health.server) || null
799
+ const serverSection = serverInfo
800
+ ? h(
801
+ 'section',
802
+ { className: 'a2aui-card' },
803
+ h('h4', { className: 'a2aui-section-title' }, t('serverStatus')),
804
+ h(
805
+ 'div',
806
+ { className: 'a2aui-facts' },
807
+ h('div', { className: 'a2aui-fact' }, h('span', { className: 'a2aui-fact-key' }, t('serverMode')), h('span', { className: 'a2aui-fact-value' }, serverInfo.mode === 'standalone' ? t('serverModeStandalone') : t('serverModeShared'))),
808
+ serverInfo.mode === 'standalone'
809
+ ? h('div', { className: 'a2aui-fact' }, h('span', { className: 'a2aui-fact-key' }, t('serverAddress')), h('span', { className: 'a2aui-fact-value' }, `${serverInfo.host}:${serverInfo.port}`))
810
+ : null,
811
+ h('div', { className: 'a2aui-fact' }, h('span', { className: 'a2aui-fact-key' }, t('serverListening')), h('span', { className: 'a2aui-fact-value' }, serverInfo.listening ? t('serverListeningYes') : t('serverListeningNo'))),
812
+ ),
813
+ serverInfo.error
814
+ ? h('p', { className: 'a2aui-status', 'data-tone': 'error' }, `${t('serverError')}: ${serverInfo.error}`)
815
+ : null,
816
+ serverInfo.mode === 'standalone' && serverInfo.listening === false
817
+ ? h('p', { className: 'a2aui-status', 'data-tone': 'warn' }, t('serverNotListening'))
818
+ : null,
819
+ )
820
+ : null
821
+
764
822
  const runtimeSection = health
765
823
  ? h(
766
824
  'section',
@@ -830,6 +888,7 @@ window.__ModuleLoader__.load({
830
888
  header,
831
889
  statusSection,
832
890
  endpointSection,
891
+ serverSection,
833
892
  runtimeSection,
834
893
  deniedSection,
835
894
  configSection,
package/cordis.patch.yml CHANGED
@@ -1,7 +1,9 @@
1
1
  # dsh-a2a bundle patch: mounts the A2A (Agent2Agent) v1.0 server row.
2
2
  #
3
- # Defaults below are safe for a loopback deployment. To expose the agent to
4
- # other hosts, set `server.publicUrl` and enable `auth` in the profile patch:
3
+ # Defaults below are safe for a loopback deployment. The row listens on its
4
+ # own port (127.0.0.1:41241) so A2A traffic does not share the DSH Web port;
5
+ # set `server.port: null` to reuse the DSH listener again. To expose the agent
6
+ # to other hosts, set `server.publicUrl` and enable `auth` in the profile patch:
5
7
  #
6
8
  # - id: a2a-server
7
9
  # name: '@ai_mind/dsh-a2a'
@@ -19,3 +21,6 @@
19
21
  name: '@ai_mind/dsh-a2a'
20
22
  config:
21
23
  enabled: true
24
+ server:
25
+ host: 127.0.0.1
26
+ port: 41241
package/lib/config.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * @module @ai_mind/dsh-a2a/config
10
10
  */
11
11
 
12
+ import { isIP } from 'node:net'
12
13
  import { isPlainObject, deepMerge, deepFreeze } from './util.js'
13
14
 
14
15
  /** Error raised for an invalid plugin configuration. */
@@ -44,6 +45,8 @@ export const DEFAULT_CONFIG = {
44
45
  mountPath: '/a2a',
45
46
  cardPath: '/.well-known/agent-card.json',
46
47
  publicUrl: null,
48
+ host: '127.0.0.1',
49
+ port: null,
47
50
  trustProxy: false,
48
51
  streaming: true,
49
52
  pushNotifications: true,
@@ -350,6 +353,14 @@ export function normalizeConfig(raw) {
350
353
  merged.server.mountPath = validatePath(merged.server.mountPath, 'server.mountPath', { reserved: true })
351
354
  merged.server.cardPath = validatePath(merged.server.cardPath, 'server.cardPath')
352
355
  merged.server.publicUrl = expectNullableString(merged.server.publicUrl, 'server.publicUrl', { allowEmpty: false })
356
+ merged.server.host = expectString(merged.server.host, 'server.host')
357
+ if (merged.server.host !== 'localhost' && isIP(merged.server.host) === 0) {
358
+ fail('server.host', 'must be an IPv4/IPv6 literal or "localhost"')
359
+ }
360
+ merged.server.port =
361
+ merged.server.port === null || merged.server.port === undefined
362
+ ? null
363
+ : expectInteger(merged.server.port, 'server.port', { min: 1, max: 65535 })
353
364
  if (merged.server.publicUrl !== null) {
354
365
  let url
355
366
  try {
package/lib/panel.js CHANGED
@@ -30,6 +30,16 @@ const FIELDS = [
30
30
  { path: 'card.description', type: 'textarea', group: 'card' },
31
31
  { path: 'card.version', type: 'string', group: 'card' },
32
32
  { path: 'server.publicUrl', type: 'string', group: 'server', nullable: true, placeholder: 'https://agent.example.com' },
33
+ { path: 'server.host', type: 'string', group: 'server', placeholder: '127.0.0.1' },
34
+ {
35
+ path: 'server.port',
36
+ type: 'integer',
37
+ group: 'server',
38
+ nullable: true,
39
+ min: 1,
40
+ max: 65535,
41
+ placeholder: '留空 = 复用 DSH 端口',
42
+ },
33
43
  { path: 'server.mountPath', type: 'string', group: 'server' },
34
44
  { path: 'server.streaming', type: 'boolean', group: 'server' },
35
45
  { path: 'server.pushNotifications', type: 'boolean', group: 'server' },
@@ -187,9 +197,20 @@ function describe(config, entry, warnings) {
187
197
  * @param {Readonly<object>} options.config the active normalized config
188
198
  * @param {string} options.mountPath A2A mount path (for messages)
189
199
  * @param {{ info: Function, warn: Function }} options.logger
200
+ * @param {() => object} [options.status] live listening-mode description
201
+ * @param {() => object} [options.health] live health snapshot
190
202
  * @returns {import('express').Router}
191
203
  */
192
- export function createPanelRouter({ ctx, config, mountPath, logger }) {
204
+ function safeHealth(health, logger) {
205
+ try {
206
+ return health()
207
+ } catch (error) {
208
+ logger.warn(`[a2a] panel health snapshot failed: ${errorMessage(error)}`)
209
+ return null
210
+ }
211
+ }
212
+
213
+ export function createPanelRouter({ ctx, config, mountPath, logger, status, health }) {
193
214
  const router = express.Router()
194
215
  const json = express.json({ limit: 256 * 1024, strict: false })
195
216
 
@@ -263,7 +284,14 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
263
284
  }
264
285
  const payload = describe(live, entry, warnings)
265
286
  res.setHeader('Cache-Control', 'no-store')
266
- res.json({ ok: true, plugin: { name: PLUGIN_NAME, version: '1.0.0' }, mountPath, ...payload })
287
+ res.json({
288
+ ok: true,
289
+ plugin: { name: PLUGIN_NAME, version: '1.1.1' },
290
+ mountPath,
291
+ ...payload,
292
+ ...(typeof status === 'function' ? { server: status() } : {}),
293
+ ...(typeof health === 'function' ? { health: safeHealth(health, logger) } : {}),
294
+ })
267
295
  })
268
296
 
269
297
  router.put('/config', json, async (req, res) => {
@@ -314,6 +342,9 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
314
342
  if (typeof body.token !== 'string') throw new Error('token must be a string')
315
343
  if (body.token.trim() === '') throw new Error('token must not be empty')
316
344
  setAt(next, 'auth.token', body.token)
345
+ // A token with auth.mode "none" would be stored but never used. Treat
346
+ // an explicitly supplied token as the intent to enable bearer auth.
347
+ if ((next.auth?.mode ?? 'none') === 'none') setAt(next, 'auth.mode', 'bearer')
317
348
  }
318
349
 
319
350
  const normalized = normalizeConfig(next)
@@ -350,7 +381,13 @@ export function createPanelRouter({ ctx, config, mountPath, logger }) {
350
381
  /* keep the submitted view when the row is mid-reload */
351
382
  }
352
383
  logger.info('[a2a] configuration updated from the settings panel')
353
- res.json({ ok: true, plugin: { name: PLUGIN_NAME, version: '1.0.0' }, mountPath, ...describe(live, liveEntry, []) })
384
+ res.json({
385
+ ok: true,
386
+ plugin: { name: PLUGIN_NAME, version: '1.1.1' },
387
+ mountPath,
388
+ ...describe(live, liveEntry, []),
389
+ ...(typeof status === 'function' ? { server: status() } : {}),
390
+ })
354
391
  })
355
392
 
356
393
  return router
package/lib/server.js CHANGED
@@ -3,12 +3,20 @@
3
3
  *
4
4
  * The official `@a2a-js/sdk` supplies the protocol engine — task lifecycle,
5
5
  * JSON-RPC and HTTP+JSON transports, SSE framing, ProtoJSON codecs, push
6
- * notifications and Agent Card signing. This module mounts that engine on the
7
- * Harness Web server and wires it to the local agent runtime.
6
+ * notifications and Agent Card signing.
7
+ *
8
+ * Two listening modes are supported:
9
+ * - shared (default): the A2A routes live on the Harness Web server, so the
10
+ * endpoint uses the GUI's port and lifecycle;
11
+ * - standalone (`server.port` set): a dedicated `node:http` listener owns the
12
+ * card and A2A routes on its own host/port, while the Harness Web server
13
+ * keeps only the public card mirror and the authenticated settings panel.
8
14
  *
9
15
  * @module @ai_mind/dsh-a2a/server
10
16
  */
11
17
 
18
+ import http from 'node:http'
19
+ import { isIP } from 'node:net'
12
20
  import express from 'express'
13
21
  import { A2A_PROTOCOL_VERSION } from '@a2a-js/sdk'
14
22
  import {
@@ -49,9 +57,31 @@ function createLogger(ctx) {
49
57
  }
50
58
  }
51
59
 
52
- /** Derive the externally visible origin, preferring the configured URL. */
60
+ /** Write an IPv6 host in URL form. */
61
+ function formatUrlHost(host) {
62
+ return isIP(host) === 6 ? `[${host}]` : host
63
+ }
64
+
65
+ /**
66
+ * Derive the externally visible origin.
67
+ *
68
+ * `server.publicUrl` always wins. A configured `server.port` selects the
69
+ * standalone listener's own origin; otherwise the Harness Web server's origin
70
+ * is used.
71
+ */
53
72
  function resolvePublicBase(config, webServer, logger) {
54
73
  if (config.server.publicUrl) return trimTrailingSlash(config.server.publicUrl)
74
+ if (config.server.port !== null) {
75
+ const host = config.server.host
76
+ if (host === '0.0.0.0' || host === '::') {
77
+ logger.warn(
78
+ `[a2a] server.host ${host} is a wildcard; advertising a loopback URL. ` +
79
+ 'Set server.publicUrl to the address remote A2A clients should use.',
80
+ )
81
+ }
82
+ const advertised = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host
83
+ return `http://${formatUrlHost(advertised)}:${config.server.port}`
84
+ }
55
85
  try {
56
86
  const origin = webServer.getOrigin()
57
87
  if (typeof origin === 'string' && origin !== '') return trimTrailingSlash(origin)
@@ -134,7 +164,8 @@ function writeError(req, res, { status, code, message, restBinding, rpcCode = -3
134
164
  /**
135
165
  * One mounted A2A deployment. Mounting is synchronous so route registration
136
166
  * can participate in Cordis effects; only request handling touches async
137
- * agent work.
167
+ * agent work. The standalone listener is created here and reports its bind
168
+ * result through {@link A2AService.serverStatus}.
138
169
  */
139
170
  export class A2AService {
140
171
  #ctx
@@ -142,6 +173,7 @@ export class A2AService {
142
173
  #logger
143
174
  #mounted = undefined
144
175
  #unmount = undefined
176
+ #standalone = undefined
145
177
  #disposed = false
146
178
 
147
179
  /**
@@ -160,16 +192,50 @@ export class A2AService {
160
192
  }
161
193
 
162
194
  /**
163
- * Register routes on the Harness Web server.
195
+ * Describe the active listening mode for health checks and the settings
196
+ * panel. In shared mode the DSH web server owns the socket, so `listening`
197
+ * only means the plugin is mounted.
198
+ *
199
+ * @returns {{ mode: 'shared'|'standalone', host: string|null, port: number|null, listening: boolean, error: string|null, publicUrl: string|null }}
200
+ */
201
+ serverStatus() {
202
+ const config = this.#config
203
+ const mounted = this.#mounted
204
+ if (config.server.port === null) {
205
+ return {
206
+ mode: 'shared',
207
+ host: null,
208
+ port: null,
209
+ listening: mounted !== undefined,
210
+ error: null,
211
+ publicUrl: mounted?.publicBaseUrl ?? null,
212
+ }
213
+ }
214
+ const state = this.#standalone
215
+ return {
216
+ mode: 'standalone',
217
+ host: config.server.host,
218
+ port: config.server.port,
219
+ listening: state?.listening === true,
220
+ error: state?.error ?? null,
221
+ publicUrl: mounted?.publicBaseUrl ?? null,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Mount the A2A deployment.
164
227
  *
165
228
  * @param {object} webServer the `webServer` service
166
- * @returns {() => Promise<void>} disposer that unregisters routes and drains work
229
+ * @returns {() => Promise<void>} disposer that unregisters routes, closes the
230
+ * standalone listener and drains work
167
231
  */
168
232
  mount(webServer) {
169
233
  if (this.#mounted !== undefined) throw new Error('a2a: already mounted')
170
234
  if (this.#disposed) throw new Error('a2a: service is disposed')
171
235
  const config = this.#config
172
236
  const logger = this.#logger
237
+ const mountPath = config.server.mountPath
238
+ const standalone = config.server.port !== null
173
239
 
174
240
  const credentials = resolveCredentials(config)
175
241
  const publicBaseUrl = resolvePublicBase(config, webServer, logger)
@@ -224,84 +290,59 @@ export class A2AService {
224
290
  config,
225
291
  credentials,
226
292
  logger,
227
- isRestBinding: (req) => isRestBinding(req, config.server.mountPath),
228
- })
229
-
230
- const app = express()
231
- app.disable('x-powered-by')
232
- app.set('trust proxy', config.server.trustProxy)
233
- app.set('query parser', 'simple')
234
-
235
- app.use(createCors(config))
236
- app.use((req, res, next) => {
237
- res.setHeader('X-Content-Type-Options', 'nosniff')
238
- next()
293
+ isRestBinding: (req) => isRestBinding(req, mountPath),
239
294
  })
240
- if (config.server.logRequests) {
241
- app.use((req, res, next) => {
242
- const started = Date.now()
243
- res.on('finish', () => {
244
- logger.info(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - started}ms)`)
245
- })
246
- next()
247
- })
248
- }
249
-
250
- // Parse bodies once with the configured limit. The SDK transports skip
251
- // their own `express.json()` because body-parser marks the request parsed.
252
- app.use(
253
- express.json({
254
- limit: config.limits.maxRequestBytes,
255
- strict: false,
256
- type: [JSON_CONTENT_TYPE, A2A_CONTENT_TYPE],
257
- }),
258
- )
259
295
 
260
- const mountPath = config.server.mountPath
261
- const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
262
- const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
263
296
  const cardRouter = agentCardHandler({
264
297
  agentCardProvider: requestHandler,
265
298
  cache: { maxAge: config.server.cardCacheSeconds },
266
299
  })
267
-
268
- // The Agent Card is discovery metadata and stays public.
269
- app.use(config.server.cardPath, cardRouter)
270
- // The settings panel is authenticated by the Harness Web connection, not
271
- // by the A2A token, and must be mounted before the A2A auth middleware.
272
- app.use(`${mountPath}/panel`, createPanelRouter({ ctx: this.#ctx, config, mountPath, logger }))
273
- // Everything else below the mount path is the A2A endpoint.
274
- app.use(mountPath, authenticator.middleware)
275
- app.use(`${mountPath}/rest`, restRouter)
276
- app.use(mountPath, jsonRpcRouter)
277
-
278
- app.get(mountPath, (req, res) => {
279
- res.setHeader('Allow', 'POST')
280
- writeError(req, res, {
281
- status: 405,
282
- code: 'METHOD_NOT_ALLOWED',
283
- message: `The A2A JSON-RPC endpoint only accepts POST; REST clients use ${mountPath}/rest.`,
284
- restBinding: false,
285
- })
300
+ const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
301
+ const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
302
+ const healthSnapshot = () =>
303
+ this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard })
304
+ const panelRouter = createPanelRouter({
305
+ ctx: this.#ctx,
306
+ config,
307
+ mountPath,
308
+ logger,
309
+ status: () => this.serverStatus(),
310
+ // The panel is same-origin with the GUI but possibly cross-origin to the
311
+ // A2A port; return the health snapshot here so the panel does not depend
312
+ // on CORS being enabled on the standalone listener.
313
+ health: healthSnapshot,
286
314
  })
287
315
 
288
- if (config.server.health) {
289
- app.get(`${mountPath}/health`, (req, res) => {
290
- res.setHeader('Cache-Control', 'no-store')
291
- res.json(this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard }))
316
+ const applyCommon = (app) => {
317
+ app.disable('x-powered-by')
318
+ app.set('trust proxy', config.server.trustProxy)
319
+ app.set('query parser', 'simple')
320
+ app.use(createCors(config))
321
+ app.use((req, res, next) => {
322
+ res.setHeader('X-Content-Type-Options', 'nosniff')
323
+ next()
292
324
  })
325
+ if (config.server.logRequests) {
326
+ app.use((req, res, next) => {
327
+ const started = Date.now()
328
+ res.on('finish', () => {
329
+ logger.info(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - started}ms)`)
330
+ })
331
+ next()
332
+ })
333
+ }
334
+ // Parse bodies once with the configured limit. The SDK transports skip
335
+ // their own `express.json()` because body-parser marks the request parsed.
336
+ app.use(
337
+ express.json({
338
+ limit: config.limits.maxRequestBytes,
339
+ strict: false,
340
+ type: [JSON_CONTENT_TYPE, A2A_CONTENT_TYPE],
341
+ }),
342
+ )
293
343
  }
294
344
 
295
- app.use((req, res) => {
296
- writeError(req, res, {
297
- status: 404,
298
- code: 'NOT_FOUND',
299
- message: `No A2A route matches ${req.method} ${req.originalUrl}`,
300
- restBinding: isRestBinding(req, mountPath),
301
- })
302
- })
303
-
304
- app.use((error, req, res, next) => {
345
+ const a2aErrorHandler = (error, req, res, next) => {
305
346
  if (res.headersSent) {
306
347
  res.end()
307
348
  return
@@ -324,9 +365,67 @@ export class A2AService {
324
365
  rpcCode: isParseError ? -32700 : isTooLarge ? -32600 : -32603,
325
366
  })
326
367
  void next
368
+ }
369
+
370
+ const panelErrorHandler = (error, req, res, next) => {
371
+ if (res.headersSent) {
372
+ res.end()
373
+ return
374
+ }
375
+ const status =
376
+ typeof error?.status === 'number' && error.status >= 400 && error.status <= 599 ? error.status : 500
377
+ if (status >= 500) logger.warn(`[a2a] panel request failed: ${errorMessage(error)}`)
378
+ res.statusCode = status
379
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
380
+ res.setHeader('Cache-Control', 'no-store')
381
+ res.end(
382
+ JSON.stringify({
383
+ error: status >= 500 ? 'INTERNAL' : 'INVALID_ARGUMENT',
384
+ message: error?.type === 'entity.parse.failed' ? 'Invalid JSON payload' : errorMessage(error),
385
+ }),
386
+ )
387
+ void next
388
+ }
389
+
390
+ const a2aApp = express()
391
+ applyCommon(a2aApp)
392
+ // The Agent Card is discovery metadata and stays public.
393
+ a2aApp.use(config.server.cardPath, cardRouter)
394
+ if (!standalone) {
395
+ // In shared mode the panel is part of the same app and must precede the
396
+ // A2A auth middleware.
397
+ a2aApp.use(`${mountPath}/panel`, panelRouter)
398
+ }
399
+ // Everything below the mount path is the A2A endpoint.
400
+ a2aApp.use(mountPath, authenticator.middleware)
401
+ a2aApp.use(`${mountPath}/rest`, restRouter)
402
+ a2aApp.use(mountPath, jsonRpcRouter)
403
+ a2aApp.get(mountPath, (req, res) => {
404
+ res.setHeader('Allow', 'POST')
405
+ writeError(req, res, {
406
+ status: 405,
407
+ code: 'METHOD_NOT_ALLOWED',
408
+ message: `The A2A JSON-RPC endpoint only accepts POST; REST clients use ${mountPath}/rest.`,
409
+ restBinding: false,
410
+ })
327
411
  })
412
+ if (config.server.health) {
413
+ a2aApp.get(`${mountPath}/health`, (req, res) => {
414
+ res.setHeader('Cache-Control', 'no-store')
415
+ res.json(healthSnapshot())
416
+ })
417
+ }
418
+ a2aApp.use((req, res) => {
419
+ writeError(req, res, {
420
+ status: 404,
421
+ code: 'NOT_FOUND',
422
+ message: `No A2A route matches ${req.method} ${req.originalUrl}`,
423
+ restBinding: isRestBinding(req, mountPath),
424
+ })
425
+ })
426
+ a2aApp.use(a2aErrorHandler)
328
427
 
329
- const callApp = (req, res) => {
428
+ const wrap = (app) => (req, res) => {
330
429
  try {
331
430
  app(req, res)
332
431
  } catch (error) {
@@ -340,11 +439,30 @@ export class A2AService {
340
439
  }
341
440
  }
342
441
  }
442
+ const callA2A = wrap(a2aApp)
443
+
444
+ let callPanel
445
+ if (standalone) {
446
+ // The settings panel stays on the Harness Web origin so it can reuse the
447
+ // GUI's authenticated browser session. It is not exposed on the A2A port.
448
+ const panelApp = express()
449
+ panelApp.disable('x-powered-by')
450
+ panelApp.set('trust proxy', config.server.trustProxy)
451
+ panelApp.use(`${mountPath}/panel`, panelRouter)
452
+ panelApp.use((req, res) => {
453
+ res.statusCode = 404
454
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
455
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `No settings panel route matches ${req.url}` }))
456
+ })
457
+ panelApp.use(panelErrorHandler)
458
+ callPanel = wrap(panelApp)
459
+ }
343
460
 
344
461
  const routeDisposers = []
345
- const registerRoute = (route, label, { optional = false } = {}) => {
462
+ let standaloneState
463
+ const registerRoute = (route, handler, label, { optional = false } = {}) => {
346
464
  try {
347
- routeDisposers.push(webServer.register({ ...route, handler: callApp }))
465
+ routeDisposers.push(webServer.register({ ...route, handler }))
348
466
  } catch (error) {
349
467
  if (optional) {
350
468
  logger.warn(`[a2a] cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
@@ -354,8 +472,70 @@ export class A2AService {
354
472
  }
355
473
  }
356
474
 
357
- registerRoute({ kind: 'exact', path: config.server.cardPath }, 'agent card')
358
- registerRoute({ kind: 'prefix', path: mountPath }, 'A2A endpoint')
475
+ const cleanupAfterFailure = () => {
476
+ for (const dispose of routeDisposers.splice(0).reverse()) {
477
+ try {
478
+ dispose()
479
+ } catch {
480
+ /* ignore during rollback */
481
+ }
482
+ }
483
+ if (standaloneState !== undefined) {
484
+ standaloneState.server.close()
485
+ standaloneState.server.closeAllConnections?.()
486
+ this.#standalone = undefined
487
+ }
488
+ void executor.dispose().catch((error) => logger.warn(`[a2a] rollback dispose failed: ${errorMessage(error)}`))
489
+ void runtime.shutdown().catch((error) => logger.warn(`[a2a] rollback shutdown failed: ${errorMessage(error)}`))
490
+ }
491
+
492
+ try {
493
+ // Discovery stays reachable on the Harness Web origin in both modes: in
494
+ // shared mode it is the endpoint, and in standalone mode it bootstraps
495
+ // the settings panel (the card itself points at the dedicated port).
496
+ registerRoute({ kind: 'exact', path: config.server.cardPath }, callA2A, 'agent card')
497
+ if (standalone) {
498
+ registerRoute(
499
+ { kind: 'prefix', path: `${mountPath}/panel` },
500
+ callPanel,
501
+ 'settings panel',
502
+ )
503
+ const server = http.createServer(callA2A)
504
+ standaloneState = {
505
+ server,
506
+ host: config.server.host,
507
+ port: config.server.port,
508
+ listening: false,
509
+ error: null,
510
+ }
511
+ this.#standalone = standaloneState
512
+ server.on('error', (error) => {
513
+ standaloneState.error = errorMessage(error)
514
+ standaloneState.listening = false
515
+ logger.error(
516
+ `[a2a] standalone listener ${config.server.host}:${config.server.port} failed: ${errorMessage(error)}`,
517
+ )
518
+ })
519
+ server.on('listening', () => {
520
+ standaloneState.listening = true
521
+ standaloneState.error = null
522
+ logger.info(
523
+ `[a2a] standalone listener ready on ${formatUrlHost(config.server.host)}:${config.server.port}`,
524
+ )
525
+ })
526
+ try {
527
+ server.listen(config.server.port, config.server.host)
528
+ } catch (error) {
529
+ standaloneState.error = errorMessage(error)
530
+ logger.error(`[a2a] standalone listener cannot bind: ${errorMessage(error)}`)
531
+ }
532
+ } else {
533
+ registerRoute({ kind: 'prefix', path: mountPath }, callA2A, 'A2A endpoint')
534
+ }
535
+ } catch (error) {
536
+ cleanupAfterFailure()
537
+ throw error
538
+ }
359
539
 
360
540
  const sweepTimer = setInterval(() => {
361
541
  try {
@@ -384,11 +564,21 @@ export class A2AService {
384
564
  runtime.start()
385
565
 
386
566
  const startedAt = Date.now()
387
- this.#mounted = { startedAt }
388
- logger.info(
389
- `A2A v${A2A_PROTOCOL_VERSION} server ready: ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
390
- `${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
391
- )
567
+ this.#mounted = { startedAt, publicBaseUrl, standalone }
568
+ if (standalone) {
569
+ logger.info(
570
+ `A2A v${A2A_PROTOCOL_VERSION} standalone server: ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
571
+ `${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
572
+ )
573
+ logger.info(
574
+ `settings panel and card mirror on the Harness Web origin: ${webServer.getOrigin()}${mountPath}/panel/config`,
575
+ )
576
+ } else {
577
+ logger.info(
578
+ `A2A v${A2A_PROTOCOL_VERSION} server ready (shared port): ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
579
+ `${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
580
+ )
581
+ }
392
582
  logger.info(
393
583
  `auth=${authenticator.describe().mode} streaming=${config.server.streaming} ` +
394
584
  `push=${config.server.pushNotifications} extendedCard=${extendedCard}`,
@@ -406,6 +596,15 @@ export class A2AService {
406
596
  logger.warn(`[a2a] route disposal failed: ${errorMessage(error)}`)
407
597
  }
408
598
  }
599
+ const state = this.#standalone
600
+ this.#standalone = undefined
601
+ if (state !== undefined) {
602
+ await new Promise((resolve) => {
603
+ state.server.close(() => resolve())
604
+ state.server.closeAllConnections?.()
605
+ })
606
+ state.listening = false
607
+ }
409
608
  await executor.dispose()
410
609
  await runtime.shutdown()
411
610
  this.#mounted = undefined
@@ -426,6 +625,7 @@ export class A2AService {
426
625
  version: config.card.version,
427
626
  publicUrl: publicBaseUrl,
428
627
  uptimeSeconds: mounted === undefined ? 0 : Math.round((Date.now() - mounted.startedAt) / 1000),
628
+ server: this.serverStatus(),
429
629
  interfaces: [
430
630
  { protocolBinding: 'JSONRPC', url: `${publicBaseUrl}${config.server.mountPath}` },
431
631
  { protocolBinding: 'HTTP+JSON', url: `${publicBaseUrl}${config.server.mountPath}/rest` },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai_mind/dsh-a2a",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Production A2A (Agent2Agent) v1.0 server for DeepSeek Harness agents: JSON-RPC + HTTP+JSON bindings, streaming, tasks, push notifications and signed Agent Cards.",
5
5
  "type": "module",
6
6
  "license": "MIT",