nexus-agentd 0.2.6 → 0.3.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.
Files changed (56) hide show
  1. package/README.md +103 -14
  2. package/dist/a2a/runtime.d.ts +4 -1
  3. package/dist/a2a/runtime.js +213 -10
  4. package/dist/acp/runtime.js +13 -6
  5. package/dist/artifact-store.d.ts +168 -0
  6. package/dist/artifact-store.js +1111 -0
  7. package/dist/cli.js +13 -14
  8. package/dist/config.js +45 -5
  9. package/dist/control-plane-validation.d.ts +54 -0
  10. package/dist/control-plane-validation.js +152 -0
  11. package/dist/control-plane.d.ts +9 -32
  12. package/dist/control-plane.js +41 -157
  13. package/dist/drivers/index.js +12 -0
  14. package/dist/drivers/pi.js +1 -1
  15. package/dist/drivers/stdio.d.ts +4 -1
  16. package/dist/drivers/stdio.js +135 -19
  17. package/dist/events.d.ts +9 -0
  18. package/dist/events.js +17 -4
  19. package/dist/index.js +10 -1
  20. package/dist/process-tree.js +15 -6
  21. package/dist/run-store.d.ts +90 -7
  22. package/dist/run-store.js +254 -22
  23. package/dist/server/auth-policy.d.ts +27 -0
  24. package/dist/server/auth-policy.js +143 -0
  25. package/dist/server/connection.d.ts +8 -0
  26. package/dist/server/connection.js +67 -0
  27. package/dist/server/diagnostics.d.ts +40 -0
  28. package/dist/server/diagnostics.js +130 -0
  29. package/dist/server/metrics.d.ts +51 -0
  30. package/dist/server/metrics.js +56 -0
  31. package/dist/server/quota.d.ts +59 -0
  32. package/dist/server/quota.js +176 -0
  33. package/dist/server/validation.d.ts +29 -0
  34. package/dist/server/validation.js +221 -0
  35. package/dist/server.d.ts +3 -0
  36. package/dist/server.js +737 -419
  37. package/dist/session/admin-runs.d.ts +73 -0
  38. package/dist/session/admin-runs.js +298 -0
  39. package/dist/session/admission.d.ts +32 -0
  40. package/dist/session/admission.js +62 -0
  41. package/dist/session/readiness.d.ts +33 -0
  42. package/dist/session/readiness.js +174 -0
  43. package/dist/session.d.ts +65 -7
  44. package/dist/session.js +256 -109
  45. package/dist/sse-writer.d.ts +7 -0
  46. package/dist/sse-writer.js +60 -0
  47. package/dist/types.d.ts +46 -2
  48. package/dist/types.js +1 -0
  49. package/dist/webui/app/sources.js +18 -9
  50. package/dist/webui/icons.d.ts +1 -0
  51. package/dist/webui/icons.js +1 -0
  52. package/dist/webui/markup.js +3 -0
  53. package/dist/webui/styles.js +105 -15
  54. package/examples/client.mjs +102 -0
  55. package/nexus-agentd.example.json +19 -0
  56. package/package.json +3 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/lumia1998/nexus-gateway/actions/workflows/ci.yml/badge.svg)](https://github.com/lumia1998/nexus-gateway/actions/workflows/ci.yml)
4
4
 
5
- `nexus-agentd` 是 Agent Nexus 的本地 Gateway。它在一台机器上统一管理 ACP 进程和远程 A2A
5
+ `nexus-agentd` 是面向通用客户端的 Agent Nexus Gateway。它在一台机器上统一管理 ACP 进程和远程 A2A
6
6
  Agent,并向 Koishi AgentNexus 或其他客户端提供 HTTP/SSE API。管理控制台使用 **Agent Nexus**
7
7
  品牌,不依赖 CDN、前端框架或父仓库。
8
8
 
@@ -13,11 +13,11 @@ npm install -g nexus-agentd
13
13
  nexus-agentd
14
14
  ```
15
15
 
16
- 首次启动会创建 `./nexus-agentd.json`,默认监听 `127.0.0.1:8787`。打开打印出的 WebUI 地址,
17
- 设置至少 12 位的 Console Password,然后登录。新安装不会自动创建 API Key;在控制台的
16
+ 首次启动会创建 `./nexus-agentd.json`,默认监听 `0.0.0.0:8787`。打开打印出的 WebUI 地址,
17
+ 输入启动终端中的 **Setup token**,设置至少 12 位的 Console Password,然后登录。新安装不会自动创建 API Key;在控制台的
18
18
  **API Keys** 页面按实际客户端需要创建。
19
19
 
20
- 局域网使用时显式监听所有网卡:
20
+ 局域网使用(所有网卡已是默认值,也可显式指定):
21
21
 
22
22
  ```bash
23
23
  mkdir -p /data/repos
@@ -47,6 +47,10 @@ nexus-agentd --config /etc/agent-nexus/nexus-agentd.json
47
47
  被当成 Console Password。升级后第一次打开 WebUI 会要求单独设置管理员密码,原有 Agent 和
48
48
  Workspace 配置保持不变。
49
49
 
50
+ 首次安装和旧配置补设控制台密码均需要 Setup token。令牌仅存在当前进程中,初始化成功即失效,
51
+ 未初始化时每次启动重新生成;不会写入 URL、配置文件或匿名接口。嵌入使用时从
52
+ `startAgentd(...).controlPlane.setupToken` 获取它。不要向不可信人员开放服务启动日志。
53
+
50
54
  ## WebUI
51
55
 
52
56
  侧栏按“运行”和“网关配置”分组,提供总览、运行记录、智能体、工作区、API 密钥五个页面;底部提供独立的“设置”和“退出登录”入口。
@@ -55,15 +59,18 @@ Workspace 配置保持不变。
55
59
  - 总览显示智能体、就绪和当前内存会话数量,并列出需要注意的智能体。
56
60
  - 运行记录把当前任务和历史任务分开显示,记录用户原始任务、真实运行阶段、状态、结果摘要、
57
61
  耗时和产物;每 5 秒自动刷新,也可查看完整详情。
58
- 顶部统计涵盖全部保留记录;列表载入最近 200 条,搜索和筛选作用于已载入的记录。
62
+ 搜索、筛选和统计作用于全部保留记录(默认最多 1000 条),默认每页 50 条,可翻页。列表只返回最多 240 字符的任务预览,详情按需读取全文。详情随列表轮询更新,
63
+ 断线会明确标注旧数据;历史产物仅保留元数据,等待输入/授权请回到创建任务的客户端处理。
59
64
  轮询保留搜索框、输入法组合态与筛选控件,表格详情和菜单支持键盘操作。
60
- - Agents 支持本地 ACP 与远程 A2A;readiness 每 20 秒自动刷新,也可手动刷新。
65
+ - Agents 支持本地 ACP 与远程 A2A;总览/智能体页在前台时每 20 秒刷新 readiness,也可手动刷新。
66
+ ACP 的“命令可用”只表示命令探测通过,不代表 ACP 握手成功;A2A 的“Card 可用”也不保证任务执行成功。
61
67
  - Workspaces 管理 ACP 的 realpath allowlist;A2A 不使用本地 Workspace。
62
68
  - API Keys 显示真实状态和最后使用时间,并提供独立的显式 reveal 操作。
63
- - 设置中的运行参数可以直接修改会话空闲有效期、ACP 单次任务超时和清理任务周期,保存后立即热生效。
64
- 运行参数与控制台密码共用右上角“保存更改”,修改智能体权限策略后列表会立即刷新。
69
+ - 登录成功即显示控制台;配置、密钥、历史和 Agent 探测独立加载。探测失败保留应用和编辑入口,并显示重试与上次成功时间。
70
+ - 设置中的运行参数与控制台密码独立保存,保留毫秒精度;只改密码不会写运行参数。
71
+ 会话空闲有效期和清理周期热生效,任务期限及智能体参数影响新建会话,已有会话保留创建时参数。
65
72
  默认值分别是 24 小时、30 分钟和 60 秒;A2A 请求超时在每个 Agent 的编辑页单独设置,默认 60 秒、
66
- 最大 30 分钟。
73
+ 最大 30 分钟。A2A 另有任务总期限和流无进度期限,见下文。
67
74
 
68
75
  ## Agent 协议
69
76
 
@@ -84,6 +91,7 @@ Workspace 配置保持不变。
84
91
 
85
92
  | Driver | 默认入口 |
86
93
  |---|---|
94
+ | `stdio` | 本地配置文件中的显式 `command`、`args` |
87
95
  | `opencode` | `opencode acp` |
88
96
  | `claude` | `claude-agent-acp` |
89
97
  | `codex` | `codex-acp` |
@@ -100,11 +108,15 @@ npm install -g \
100
108
  pi-acp
101
109
  ```
102
110
 
103
- `command`、`args`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
111
+ `command`、`args`、`probeArgs`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI 不接受这些
104
112
  字段。Workspace 在启动进程前经过 `realpath` 边界校验。
105
113
 
106
- ACP 权限策略支持 `ask`(询问)、`allow`(始终允许)和 `deny`(拒绝)。`allow` 会优先选择
107
- Agent 提供的 `allow_once`,不创建待确认请求;它适合管理员明确授权、工作区边界可信的自动化 Agent。
114
+ 通用 `stdio` 驱动用于自定义 ACP 入口,必须指定 `command`;用 `probeArgs` 指定命令可用性检查参数(例如 `["--version"]`)。编辑其他字段时保留这些本地配置。命令探测不等于 ACP 握手成功。
115
+
116
+ ACP 权限策略支持 `ask`(询问)、`allow`(自动允许单次)和 `deny`(拒绝)。`allow` 只选择
117
+ Agent 提供的 `allow_once`,缺少该选项则取消授权,不再回退到永久授权。`ask` 模式下普通
118
+ `action: "accept"` 也只选择单次授权;永久授权需要客户端明确传入对应 `optionId`。
119
+ 这不限制重复的单次自动授权,但只提供永久选项的 Agent 需要改用 `ask` 并显式处理。
108
120
  默认仍为 `ask`。
109
121
 
110
122
  ### A2A
@@ -135,6 +147,25 @@ Agent Card 和它声明的全部接口 URL 必须使用 HTTP(S),不含用户
135
147
  旧配置中的 `agentUrl` 仍按“服务根地址 + `/.well-known/agent-card.json`”方式发现 Card,无需手工
136
148
  迁移;在 WebUI 中保存一次后会写入新的 `agentCardUrl` 字段。
137
149
 
150
+ A2A 超时分为三种,均为整数毫秒:
151
+
152
+ | 字段 | 作用 | 默认 / 范围 |
153
+ |---|---|---|
154
+ | `timeoutMs` | Card、普通请求全程及流建连 | 60 秒;1 秒至 30 分钟 |
155
+ | `taskTimeoutMs` | 单轮任务总期限 | 继承全局 `promptTimeoutMs`;10 秒至 24 小时 |
156
+ | `streamIdleTimeoutMs` | 流持续没有进度时的等待期限 | 继承 `timeoutMs`;1 秒至 30 分钟 |
157
+
158
+ 旧配置的 `timeoutMs` 继续限制请求,并作为未配置流失联期限时的默认值;不再隐式缩短任务总期限。长任务持续输出时可超过请求超时。通过管理 API 更新时,省略可选字段保留原值,传 `null` 清除覆盖并恢复继承。
159
+
160
+ 控制面更新的影响如下(直接编辑本地文件后需重启,或由下一次控制面保存重新载入):
161
+
162
+ | 配置 | 已有会话 / 新会话 |
163
+ |---|---|
164
+ | Key 启用、scope、轮换与删除 | 新请求立即校验,受影响旧 SSE 立即关闭;任务继续 |
165
+ | 会话 TTL、清理周期 | 当前清理任务使用新值 |
166
+ | Agent 参数、权限策略、任务期限、Workspace | 新会话使用新值;已有会话和 ACP 文件来源根保留创建时值 |
167
+ | 监听地址、端口、来源、Cookie/HTTP 连接设置 | 重启生效 |
168
+
138
169
  ## 配置
139
170
 
140
171
  推荐从首次启动生成的待初始化配置开始。完整示例见
@@ -228,8 +259,21 @@ POST /v1/sessions/:id/requests/:requestId/resolve
228
259
  POST /v1/sessions/:id/artifacts/publish
229
260
  POST /v1/sessions/:id/cancel
230
261
  GET /v1/sessions/:id/events
262
+ GET /v1/runs/:runId/artifacts/:artifactId
231
263
  ```
232
264
 
265
+ 控制台运行详情支持取消、补充输入、显式选择权限和重试。管理员 Cookie 可以跨 Key 执行这些操作;数据面仍检查原 Key 归属及 Agent scope。对应接口为 `POST /v1/admin/runs/:id/cancel`、`POST /v1/admin/runs/:id/respond` 和 `POST /v1/admin/runs/:id/retry`。取消和重试发送 `{}`;回复发送当前 `requestId` 与 `message`、`optionId` 或 `action`。过期请求及非当前轮次不能修改后续任务。
266
+
267
+ “重置/重试”以原任务创建新 Session,保留旧记录和原 Key 归属,并记录 `retryOfRunId`。运行中、任务文本被截断或带输入附件的历史不能直接重试;这些情况需要调用方重新提交完整任务及附件。新任务使用当前 Agent 配置。
268
+
269
+ 重复的重试请求在进程内复用已创建的任务:成功去重记录最多保留 512 条、24 小时,目标历史已被淘汰时失效。该机制防止重复点击,不提供跨网关重启的持久幂等保证;重启或保留窗口结束后,再次重试可能创建新任务。
270
+
271
+ 产物有可用内容时持久化到运行历史文件旁的 `.artifacts` 目录;仅有远端 URL 的产物保留元数据,不主动下载。详情中的 `downloadable` 和 `storageStatus` 表示下载状态;管理员使用 `GET /v1/admin/runs/:runId/artifacts/:artifactId`,数据面使用上表接口。下载需要身份验证,不能将链接作为公开分享地址。
272
+
273
+ 本地配置的 `quotas` 设置每 Key 的 Session、运行任务、SSE 和上传字节限制;默认分别为 16、4、8、32 MiB,连接数量默认也受全局上限约束。`history` 默认保留 1000 条、30 天、64 MiB;`artifacts` 默认保留 30 天、总量 512 MiB、单项 12 MiB、排队内容 64 MiB。活动任务受保护,因此历史预算不是强制截断活动记录的硬上限。完整字段见 `nexus-agentd.example.json`。
274
+
275
+ `GET /v1/admin/metrics` 提供管理指标;`POST /v1/admin/agents/:id/diagnostics` 以空 JSON 对象发起显式连接诊断,不提交任务提示词。通用 ACP 命令、参数和环境变量仍在本地配置中管理,控制台提供接入说明与诊断入口。
276
+
233
277
  `/v1/meta` 和 Session 响应包含 Gateway `instanceId`;完成的 Session 还包含与当前 Run 绑定的
234
278
  `completion` 证明,客户端可识别进程重启和迟到/伪造的完成状态。授权与输入通过精确的
235
279
  `requestId` 解析;过期 ID 返回 `409`,不会误答后续请求。`DELETE /v1/sessions/:id` 会取消活动任务、
@@ -245,17 +289,60 @@ API Key 的 Agent scope 在 Agent inventory、Session 创建和后续 Session
245
289
  还绑定创建它的 Key,其他 Key 即使拥有同一 Agent scope 也不能读取或控制该 Session。
246
290
  运行记录接口仅接受管理员 Cookie,数据面 API Key 无权读取。
247
291
 
292
+ Key 停用、删除、轮换及 scope 缩小时,受影响的现有 SSE 会断开;已经接受的任务继续执行,断开输出不等于取消任务。轮换保留 Key ID,新密钥可在原 scope 内继续原 Session;停用后可重新启用恢复访问。删除后不能用其他 Key 接管原 Session,任务结果仍可在管理员历史中查看,等待输入的任务受原有超时及清理规则约束。
293
+
294
+ ### SSE 断线恢复
295
+
296
+ 使用 `Last-Event-ID` 请求头或 `?after=` 恢复,客户端按事件 ID 去重。游标非法、超前或已被淘汰时,网关发送独立的 `event: reset` 控制帧,包含 `reason`(`invalid` / `ahead` / `expired`)、`earliestId`、`latestId` 与 `snapshotUrl`。空日志的最早 ID 为 `null`,最新 ID 为 `"0"`。
297
+
298
+ 收到 reset 后,读取同一 Session 的快照、替换当前展示,再使用快照的 `lastEventId` 重连。快照只恢复当前状态,不能补回已淘汰的完整事件历史。首次连接没有游标且历史已淘汰时也会 reset;网关重启导致原 Session 返回 404,需要新建会话。
299
+
300
+ [`examples/client.mjs`](./examples/client.mjs) 提供带 Bearer 鉴权、去重、指数退避、心跳期限及缺口恢复的 Node 20+ 客户端:
301
+
302
+ ```js
303
+ import { createGatewayClient } from './examples/client.mjs'
304
+ const client = createGatewayClient('http://127.0.0.1:8787', process.env.NEXUS_API_KEY)
305
+ const session = await client.createSession('codex')
306
+ await client.message(session.id, '检查当前项目')
307
+ for await (const event of client.events(session.id)) {
308
+ if (event.type === 'reset') console.log('替换当前状态:', event.data)
309
+ else console.log(event)
310
+ if (['completed', 'failed', 'canceled'].includes(event.type) ||
311
+ event.type === 'reset' && ['completed', 'failed', 'canceled'].includes(event.data.state)) break
312
+ }
313
+ // 收到 pendingRequest 后,由用户明确选择对应 optionId 或输入:
314
+ // await client.resolve(session.id, pendingRequest.id, { optionId: '实际的一次授权选项ID' })
315
+ // await client.resolve(session.id, pendingRequest.id, { message: '用户的回复' })
316
+ // await client.cancel(session.id) // 取消任务;AbortSignal 只停止客户端监听
317
+ // await client.close(session.id) // 释放 Session
318
+ ```
319
+
320
+ 示例遇到 401 / 403 / 404 会停止重连并向调用方抛错,需要更新凭据、权限或会话。不要把自动重连误当成任务重试。
321
+
248
322
  ## 局域网安全
249
323
 
250
- - 默认仍只监听 localhost;需要 LAN 时显式使用 `--host 0.0.0.0`,并用主机防火墙限制来源。
324
+ - 默认监听 `0.0.0.0`;已有配置的监听值不会被覆盖。用主机防火墙限制来源。
251
325
  - LAN 上的纯 HTTP 为兼容 Cookie 默认不设置 `Secure`;跨不可信网络应放在 HTTPS/mTLS 反向代理
252
326
  或可信隧道后,并将 `secureAdminCookies` 设为 `true`。
253
- - 管理写操作要求同源 `Origin`,Cookie 使用 `SameSite=Strict`;登录和无效 API Key 有失败限速。
327
+ - 控制台和初始化接口校验 Host,默认允许 localhost、回环地址、请求到达的本机 IP 和配置的具体监听主机。
328
+ 自定义域名 / TLS 反代在配置中增加 `"publicOrigins": ["https://gateway.example.com"]`,修改后重启。
329
+ 配置值必须是准确的协议、主机、端口组合,不带路径或尾斜杠;反代保留该 Host 与 Origin。
330
+ 不信任客户端提供的 X-Forwarded-*。数据面不强制浏览器 Origin,保留通用 Bearer 客户端兼容性。
331
+ - 管理写操作要求完整同源 `Origin`,Cookie 使用 `SameSite=Strict`;登录、初始化失败、无效 API Key 有限速。
332
+ reveal 每来源每分钟最多 20 次并记录不含密钥的审计事件。停用、删除和轮换后的旧 Key 不再回退到启动配置。
333
+ - 并发 Session 创建在异步初始化前预占容量;readiness 按 Key scope 先过滤,每 Agent 合并探测,
334
+ 手动刷新最短间隔 5 秒,普通缓存 20 秒,最多 4 个探测同时进行。
335
+ - 每条 SSE 连接的待发送缓冲最多 512 KiB,背压超过 5 秒断开并释放槽位;客户端应携带 Last-Event-ID
336
+ 重连;游标缺口按上述 reset 协议恢复。控制面持久化 Key 变更后立即关闭失去授权的旧流。
337
+ - `workspaceRoots` 限制网关处理的 cwd / 文件来源,Agent 的 workspace 是默认值,客户端仍可选择任何允许根内路径。
338
+ 这不是 OS 沙箱,也不是不互信租户隔离;ACP 子进程拥有服务账号的系统权限。
254
339
  - 不直接暴露公网。使用专用低权限系统账号运行 Gateway。
255
340
  - 配置更新使用 `0600` 临时文件校验后原子替换;Secret 不进入普通响应和结构化错误日志。
256
341
 
257
342
  ## 验证
258
343
 
344
+ 生产部署与升级使用统一的 SSH Key / systemd 流程,见[部署手册](./deploy-manual.md)。配置和历史存放在独立状态目录,升级失败会恢复旧版本。项目内的开发运行数据可放在已忽略的 `data/` 或 `runtime/` 中。
345
+
259
346
  ```bash
260
347
  npm test
261
348
  npm run typecheck
@@ -263,6 +350,8 @@ npm run build
263
350
  npx playwright install chromium
264
351
  npm run test:webui
265
352
  npm pack --dry-run --json
353
+ node scripts/package-deploy.mjs
354
+ node scripts/package-smoke.mjs nexus-gateway.tar.gz
266
355
  ```
267
356
 
268
357
  ## License
@@ -19,9 +19,12 @@ export declare class A2AClientRuntime implements AgentSessionRuntime {
19
19
  private sawTaskStatus;
20
20
  private sawAgentMessage;
21
21
  private completionProof?;
22
+ private readonly taskTimeoutMs;
23
+ private readonly streamIdleTimeoutMs;
24
+ private taskDeadlineAt?;
22
25
  constructor(config: AgentdA2AConfig, sink: AgentSessionSink, promptTimeoutMs: number);
23
26
  start(): Promise<void>;
24
- prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
27
+ prompt(message: string, attachments?: AgentdInputAttachment[], continuation?: boolean): Promise<void>;
25
28
  respondPending(response: AgentdPendingResponse | string, attachments?: AgentdInputAttachment[]): Promise<void>;
26
29
  cancel(): Promise<void>;
27
30
  dispose(): Promise<void>;
@@ -53,10 +53,19 @@ export class A2AClientRuntime {
53
53
  sawTaskStatus = false;
54
54
  sawAgentMessage = false;
55
55
  completionProof;
56
+ taskTimeoutMs;
57
+ streamIdleTimeoutMs;
58
+ taskDeadlineAt;
56
59
  constructor(config, sink, promptTimeoutMs) {
57
60
  this.config = config;
58
61
  this.sink = sink;
59
62
  this.promptTimeoutMs = promptTimeoutMs;
63
+ // Keep inheritance at runtime so changing the gateway-wide prompt
64
+ // timeout continues to affect agents that did not opt into a local
65
+ // task deadline. The per-request timeout is deliberately excluded
66
+ // from this calculation: it only bounds connection/headers.
67
+ this.taskTimeoutMs = positiveTimeout(config.taskTimeoutMs ?? promptTimeoutMs, promptTimeoutMs);
68
+ this.streamIdleTimeoutMs = positiveTimeout(config.streamIdleTimeoutMs ?? config.timeoutMs, config.timeoutMs || 60_000);
60
69
  }
61
70
  async start() {
62
71
  if (this.disposed)
@@ -78,7 +87,7 @@ export class A2AClientRuntime {
78
87
  throw new Error('A2A runtime is disposed');
79
88
  this.sink.setState('created');
80
89
  }
81
- async prompt(message, attachments = []) {
90
+ async prompt(message, attachments = [], continuation = false) {
82
91
  if (!this.client || !this.card)
83
92
  throw new Error('A2A runtime is not connected');
84
93
  if (this.prompting)
@@ -93,9 +102,14 @@ export class A2AClientRuntime {
93
102
  this.completionProof = undefined;
94
103
  this.sink.clearPending();
95
104
  this.sink.setState('running');
105
+ const taskDeadlineAt = continuation && this.taskDeadlineAt !== undefined
106
+ ? this.taskDeadlineAt
107
+ : Date.now() + this.taskTimeoutMs;
108
+ this.taskDeadlineAt = taskDeadlineAt;
96
109
  const controller = new AbortController();
97
110
  this.activeController = controller;
98
- const timer = setTimeout(() => controller.abort(new Error('A2A message timed out')), Math.min(this.promptTimeoutMs, this.config.timeoutMs || this.promptTimeoutMs));
111
+ const remainingTaskMs = Math.max(1, taskDeadlineAt - Date.now());
112
+ const timer = setTimeout(() => controller.abort(new A2ATaskTimeoutError(this.taskTimeoutMs)), remainingTaskMs);
99
113
  timer.unref?.();
100
114
  try {
101
115
  const request = this.messageRequest(message, attachments);
@@ -137,6 +151,9 @@ export class A2AClientRuntime {
137
151
  if (this.activeController === controller)
138
152
  this.activeController = undefined;
139
153
  this.prompting = false;
154
+ if (this.sink.state !== 'input_required' && !this.queuedResponse) {
155
+ this.taskDeadlineAt = undefined;
156
+ }
140
157
  finish();
141
158
  this.promptFinished = undefined;
142
159
  }
@@ -162,15 +179,19 @@ export class A2AClientRuntime {
162
179
  this.sink.setState('running');
163
180
  void previous.then(async () => {
164
181
  this.queuedResponse = false;
165
- if (this.disposed || this.sink.state === 'canceled')
182
+ if (this.disposed ||
183
+ this.sink.state === 'canceled' ||
184
+ this.sink.state === 'failed' ||
185
+ (this.taskDeadlineAt !== undefined && this.taskDeadlineAt <= Date.now()))
166
186
  return;
167
- await this.prompt(message, attachments);
187
+ await this.prompt(message, attachments, true);
168
188
  }).catch((error) => {
169
189
  if (!this.disposed && this.sink.state !== 'canceled')
170
190
  this.sink.setState('failed', errorMessage(error));
171
191
  });
172
192
  }
173
193
  async cancel() {
194
+ this.taskDeadlineAt = undefined;
174
195
  this.sink.clearPending();
175
196
  this.sink.setState('canceled');
176
197
  this.activeController?.abort(new Error('A2A session canceled'));
@@ -190,6 +211,7 @@ export class A2AClientRuntime {
190
211
  }
191
212
  async dispose() {
192
213
  this.disposed = true;
214
+ this.taskDeadlineAt = undefined;
193
215
  this.activeController?.abort(new Error('A2A runtime disposed'));
194
216
  this.activeController = undefined;
195
217
  }
@@ -432,13 +454,194 @@ function authenticatedFetch(config) {
432
454
  config.auth.value) {
433
455
  headers.set(config.auth.headerName, config.auth.value);
434
456
  }
435
- return fetch(input, {
436
- ...init,
437
- headers,
438
- redirect: 'error',
439
- signal: init.signal || AbortSignal.timeout(config.timeoutMs || 60_000)
440
- });
457
+ const timeoutMs = config.timeoutMs || 60_000;
458
+ const timeoutController = new AbortController();
459
+ const signals = [timeoutController.signal];
460
+ if (init.signal)
461
+ signals.push(init.signal);
462
+ const signal = AbortSignal.any(signals);
463
+ const requestTimeout = new A2ARequestTimeoutError(timeoutMs);
464
+ let bodyAbort;
465
+ const timer = setTimeout(() => {
466
+ timeoutController.abort(requestTimeout);
467
+ bodyAbort?.();
468
+ }, timeoutMs);
469
+ timer.unref?.();
470
+ try {
471
+ const response = await fetch(input, {
472
+ ...init,
473
+ headers,
474
+ redirect: 'error',
475
+ signal
476
+ });
477
+ // Only an SSE response is an intentionally long-lived request.
478
+ // Card and ordinary JSON calls retain timeoutMs through the body;
479
+ // otherwise a slow/trickling Card could evade the request bound.
480
+ if (isEventStream(response)) {
481
+ clearTimeout(timer);
482
+ return withStreamIdleTimeout(response, config.streamIdleTimeoutMs || timeoutMs);
483
+ }
484
+ if (!response.body) {
485
+ clearTimeout(timer);
486
+ return response;
487
+ }
488
+ const deadline = responseDeadlineBody(response.body, requestTimeout, () => clearTimeout(timer));
489
+ bodyAbort = deadline.abort;
490
+ return new Response(deadline.stream, {
491
+ status: response.status,
492
+ statusText: response.statusText,
493
+ headers: response.headers
494
+ });
495
+ }
496
+ catch (error) {
497
+ clearTimeout(timer);
498
+ if (timeoutController.signal.aborted && !init.signal?.aborted) {
499
+ throw requestTimeout;
500
+ }
501
+ throw error;
502
+ }
503
+ };
504
+ }
505
+ class A2ARequestTimeoutError extends Error {
506
+ constructor(timeoutMs) {
507
+ super(`A2A request timed out after ${timeoutMs}ms`);
508
+ this.name = 'A2ARequestTimeoutError';
509
+ }
510
+ }
511
+ class A2ATaskTimeoutError extends Error {
512
+ constructor(timeoutMs) {
513
+ super(`A2A task timed out after ${timeoutMs}ms`);
514
+ this.name = 'A2ATaskTimeoutError';
515
+ }
516
+ }
517
+ class A2AStreamIdleTimeoutError extends Error {
518
+ constructor(timeoutMs) {
519
+ super(`A2A stream made no progress for ${timeoutMs}ms`);
520
+ this.name = 'A2AStreamIdleTimeoutError';
521
+ }
522
+ }
523
+ function withStreamIdleTimeout(response, timeoutMs) {
524
+ if (!response.body || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
525
+ return response;
526
+ const body = idleTimeoutBody(response.body, timeoutMs);
527
+ return new Response(body, {
528
+ status: response.status,
529
+ statusText: response.statusText,
530
+ headers: response.headers
531
+ });
532
+ }
533
+ function isEventStream(response) {
534
+ return response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() ===
535
+ 'text/event-stream';
536
+ }
537
+ function responseDeadlineBody(body, timeoutError, cleanup) {
538
+ const reader = body.getReader();
539
+ let controllerRef;
540
+ let closed = false;
541
+ let timedOut = false;
542
+ const abort = () => {
543
+ if (closed || timedOut)
544
+ return;
545
+ timedOut = true;
546
+ void reader.cancel(timeoutError).catch(() => undefined);
547
+ controllerRef?.error(timeoutError);
548
+ };
549
+ const stream = new ReadableStream({
550
+ start(controller) {
551
+ controllerRef = controller;
552
+ },
553
+ async pull(controller) {
554
+ if (closed || timedOut)
555
+ return;
556
+ try {
557
+ const result = await reader.read();
558
+ if (timedOut)
559
+ return;
560
+ if (result.done) {
561
+ closed = true;
562
+ cleanup();
563
+ controller.close();
564
+ return;
565
+ }
566
+ controller.enqueue(result.value);
567
+ }
568
+ catch (error) {
569
+ if (timedOut)
570
+ return;
571
+ closed = true;
572
+ cleanup();
573
+ controller.error(error);
574
+ }
575
+ },
576
+ async cancel(reason) {
577
+ closed = true;
578
+ cleanup();
579
+ await reader.cancel(reason);
580
+ }
581
+ });
582
+ return { stream, abort };
583
+ }
584
+ function idleTimeoutBody(body, timeoutMs) {
585
+ const reader = body.getReader();
586
+ let controllerRef;
587
+ let timer;
588
+ let closed = false;
589
+ let timedOut = false;
590
+ const clearIdleTimer = () => {
591
+ if (timer)
592
+ clearTimeout(timer);
593
+ timer = undefined;
441
594
  };
595
+ const armIdleTimer = () => {
596
+ clearIdleTimer();
597
+ timer = setTimeout(() => {
598
+ if (closed || timedOut)
599
+ return;
600
+ timedOut = true;
601
+ const error = new A2AStreamIdleTimeoutError(timeoutMs);
602
+ void reader.cancel(error).catch(() => undefined);
603
+ controllerRef?.error(error);
604
+ }, timeoutMs);
605
+ timer.unref?.();
606
+ };
607
+ return new ReadableStream({
608
+ start(controller) {
609
+ controllerRef = controller;
610
+ armIdleTimer();
611
+ },
612
+ async pull(controller) {
613
+ if (closed || timedOut)
614
+ return;
615
+ try {
616
+ const result = await reader.read();
617
+ if (timedOut)
618
+ return;
619
+ if (result.done) {
620
+ closed = true;
621
+ clearIdleTimer();
622
+ controller.close();
623
+ return;
624
+ }
625
+ armIdleTimer();
626
+ controller.enqueue(result.value);
627
+ }
628
+ catch (error) {
629
+ if (timedOut)
630
+ return;
631
+ closed = true;
632
+ clearIdleTimer();
633
+ controller.error(error);
634
+ }
635
+ },
636
+ async cancel(reason) {
637
+ closed = true;
638
+ clearIdleTimer();
639
+ await reader.cancel(reason);
640
+ }
641
+ });
642
+ }
643
+ function positiveTimeout(value, fallback) {
644
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
442
645
  }
443
646
  function artifactFromA2A(artifact) {
444
647
  const text = textParts(artifact.parts).join('') || undefined;
@@ -186,11 +186,19 @@ export class AcpProcessRuntime {
186
186
  this.finishPermission({ outcome: { outcome: 'cancelled' } });
187
187
  return;
188
188
  }
189
- if (response.action === 'accept') {
190
- const option = options.find((item) => item.kind?.startsWith('allow')) ||
191
- options.find((item) => !(item.kind?.startsWith('reject') ?? false));
189
+ // Protocol IDs are opaque: never interpret a numeric ID as an index or
190
+ // fold its case into a different (possibly permanent) permission.
191
+ if (response.optionId !== undefined) {
192
+ const option = options.find((item) => item.id === response.optionId);
193
+ if (!option)
194
+ throw new Error('Permission optionId does not match an offered option');
195
+ this.finishPermission({ outcome: { outcome: 'selected', optionId: option.id } });
196
+ return;
197
+ }
198
+ if (response.action === 'accept' && !response.optionId) {
199
+ const option = options.find((item) => item.kind === 'allow_once');
192
200
  if (!option) {
193
- throw new Error('Permission request does not provide an allow option');
201
+ throw new Error('Permission request requires an explicit optionId; no allow_once option is available');
194
202
  }
195
203
  this.finishPermission({
196
204
  outcome: { outcome: 'selected', optionId: option.id }
@@ -298,8 +306,7 @@ export class AcpProcessRuntime {
298
306
  : { outcome: { outcome: 'cancelled' } };
299
307
  }
300
308
  if (this.driver.permissionPolicy === 'allow') {
301
- const allow = params.options.find((option) => option.kind.toLowerCase() === 'allow_once') ||
302
- params.options.find((option) => option.kind.toLowerCase().startsWith('allow'));
309
+ const allow = params.options.find((option) => option.kind.toLowerCase() === 'allow_once');
303
310
  return allow
304
311
  ? {
305
312
  outcome: {