refactor(agents): move cross-runtime and A2A to a follow-up PR

This PR now carries local collaboration only: agents on this computer,
shared threads, streaming, waiting states, the @ picker and the Agent
page with Agents and Conversations tabs.

Moved out, to return in a stacked PR: agent hosts (join links, the
--join and --agent-host-* flags, enrollment, heartbeat, lease and
pickup routes, the host client and Codex host sessions), per-agent
runtime and program binding, the Runtimes tab and Add runtime dialog,
the A2A server, grants, external intake and agent sharing, and the
@a2a-js/sdk dependency. Records written by builds that had them keep
loading; the extra fields are ignored.
This commit is contained in:
yiliang114 2026-09-24 10:23:50 +09:00
parent bafbd66226
commit 024df7e887
56 changed files with 181 additions and 6902 deletions

View file

@ -1,76 +0,0 @@
# P1:冻结的外部契约
状态:契约已冻结并有可执行落点;尚未实现任何传输层,也没有跑通任何互通。2026-09-09。
上游为[接续架构](./2026-09-09-agent-service-collaboration.md)与[实施计划](../plans/2026-09-09-agent-service-collaboration-plan.md)的 P1。本文只记录“说定了什么”,不宣称“跑通了什么”。
可执行部分在 `packages/core/src/agents/workspace-agents/a2a-contract.ts`(当时由 `scripts/audit/run-workspace-agents.mjs` 第 29 节断言,227 passed / 0 failed;该脚本及只供它断言的常量后已移除)。文档与代码不一致时以代码为准。
## 1. 冻结的版本与绑定
| 项 | 取值 | 出处 |
| --------------- | ------------------------------------------------- | --------------------------------------------- |
| 协议版本 | `1.0`(`Major.Minor`,wire header `A2A-Version`) | 规范;`@a2a-js/sdk` 的 `A2A_PROTOCOL_VERSION` |
| 传输绑定 | `JSONRPC`(唯一一个) | `AgentInterface.protocolBinding` |
| SDK | `@a2a-js/sdk@1.1.0`,Apache-2.0,node ≥ 20 | npm registry |
| Agent Card 路径 | `.well-known/agent-card.json` | 规范 §8(RFC 8615) |
| Content-Type | `application/a2a+json` | SDK 常量 |
选 JSON-RPC 而非 gRPC:daemon 本来就是 Express,SDK 直接提供 `./server/express`;gRPC 会为我们用不到的能力引入 `@grpc/grpc-js` 与 `@bufbuild/protobuf` 两个运行时 peer。
规范定义三种绑定但**不强制任何一种**,所以“支持 A2A”必须落到具体绑定才有意义。
## 2. 必需与可选操作
必需(`A2ARequestHandler` 的命名):`sendMessage`、`getTask`、`listTasks`、`cancelTask`、`getAuthenticatedExtendedAgentCard`。五个全部应答之前不得对外声称 A2A。
可选,第一版**一个都不取**:`sendMessageStream` / `resubscribe`(需 `streaming`)、四个 push notification 操作(需 `pushNotifications`)。两者都只是“更早知道任务状态”的手段,轮询 `getTask` 回答同一个问题,而且不引入第二条需要单独做可靠的投递路径。
## 3. 单位映射(本文最要紧的一条决定)
**A2A `Task` = 本地一个 `Thread`,不是一个 `ThreadRun`。**
Task 会经历 `INPUT_REQUIRED` 再收到后续输入,这正是一个 thread 被回答后继续被推进;而 run 是单次 turn,在协议里没有对应物。相应地 **A2A `contextId` = `rootThreadId`**——规范把它称作“the contextual collection of interactions”,那正是一个父 thread 连同它分裂出的子 thread。`Message` ↔ `ThreadMessage`。
| 本地 `ThreadStatus` | A2A `TaskState` | 说明 |
| ------------------- | --------------------------- | ------------------------ |
| `open` | `TASK_STATE_SUBMITTED` | |
| `in_progress` | `TASK_STATE_WORKING` | |
| `blocked` | `TASK_STATE_INPUT_REQUIRED` | |
| `in_review` | `TASK_STATE_INPUT_REQUIRED` | 见下 |
| `done` | `TASK_STATE_COMPLETED` | 唯一映射到终态的本地状态 |
`in_review` 映到 `INPUT_REQUIRED` 而不是 `WORKING`:工作没有在推进,且要由人解除,这正是该状态对“我该不该继续等”的调用方的含义。代价是**“提了问题”与“提交待评审”的区别在边界上丢失**,只在扩展 metadata 里保留。
新增 `ThreadStatus` 而不决定它对外长什么样,会让 `toA2ATaskState` 抛错而不是默认——这是断言覆盖的一项。
## 4. 不支持项(逐项)
- **远端用量不随 Task/Message 回报。** A2A 1.0 的数据模型里根本没有用量或 token 字段。因此**不能要求**第三方 agent 报告用量。我们自己的数字走 `Task.metadata` 下的扩展;**准入必须把“缺失”当作未知而非 0**,否则一个拒绝报告的远端 agent 就等于免费调用。
- **幂等只有 `MAY`。** 规范说 agent _may_ 用 `Message.messageId` 去重,而这个 id 由客户端自己生成、且没有作用域。两个不同调用方可以给出同一个 id。所以服务端自己加作用域键:`externalRequestKey(callerId, targetAgentId, messageId)`,按认证调用方与目标 agent 限定。三段用长度前缀拼接而非分隔符连接——id 是外部来的不透明字符串,能把分隔符塞进 id 的调用方本可以伪造出别人的键(这一点有断言,且变异验证过)。
**该键必须与“接单”写在同一次写入里。** 事后补写的键无法回答它存在的那个问题(重试是否与正在接受的请求是同一个),而“同键不同内容明确拒绝”也就无从判断。
- **三处本地状态缺口:** `TASK_STATE_REJECTED`(agent 拒绝接活)与 `TASK_STATE_AUTH_REQUIRED` 在本地模型里没有对应物;**thread 级的取消也没有**——`ThreadStatus` 没有该成员,只有 run 有。所以**入站 `cancelTask` 今天无法在本地表达**,P2 必须先补上,才能声称取消可用。
## 5. run 帧的非 `_meta` 通道
本地用 ACP prompt 的 `_meta` 传 run 帧,那是 daemon 的信任边界,**外部不可达也不应可达**。外部任务另用一条:在 `AgentCapabilities.extensions` 里声明扩展 URI `https://qwenlm.github.io/qwen-code/a2a/workspace-agents/v1`,帧与用量都放在 `Task.metadata` 该 URI 下。
声明为 `required: false`:忽略该扩展的客户端仍能拿到正确的 Task / Message 语义,只是看不到用量。
## 6. 互通验收者
选 **`a2a-sdk`(Python,PyPI 1.1.2,requires-python ≥ 3.10)**,仓库 `a2aproject/a2a-python`。
它与我们服务端所用的 `@a2a-js/sdk` 是不同语言、不同代码库,因此“两端都用自家客户端自测”这条被架构 §6 排除的情形不成立。用 `@a2a-js/sdk` 自带的 client 顶多算冒烟,不作为兼容证据。
## 7. 仍需人来定的事(阻塞 P2,不阻塞 P1)
1. 实际 A/B 环境与可达方式(谁能到达谁;是否需要 P4 的出站通道提前)。
2. 首个对外开放的 Agent 及其执行权限范围。
3. 审批接收人。
另有一项来自架构 §5、须在 P3 前定:Codex turn 结束时“什么信号算明确任务结果”的映射。
## 8. 本轮没有做的事
没有实现任何 A2A 路由、没有 Agent Card 发布、没有认证、没有接单存储、没有装 `@a2a-js/sdk`(依赖尚未加入 `package.json`)。P1 的门槛是“逐项记录映射与不支持项并选定验收者”,本文与 `a2a-contract.ts` 是那个记录;跑通互通是 P2 的事。

View file

@ -1,186 +0,0 @@
# 实验性 Agent 服务与双向协作
状态:接续设计,尚未实现。2026-09-09。
核心目标:Qwen Code 既能开放本地 Agent 给外部授权调用,也能联系本地或远程的已有 Agent;用户仍从原有对话入口发起工作,在同一工作界面查看分工、插话和验收。独立身份不要求永久运行一个进程,外部 Agent 不要求成为 Qwen subagent 或 Team 成员。
## 1. 范围与证据
本次为 #11206 的文档接续提交,没有修改生产代码、运行模型、执行安装脚本或跑本地 CI。本文件与[接续计划](../plans/2026-09-09-agent-service-collaboration-plan.md)共同描述新方向,不把建议记为实现。
代码核对固定在主 PR #11206 的 `6a69c0b5bbf1232644a297b567bb36350eb58ff4`;文档最初在独立 fork 编写,再以该提交为父提交接回 PR 分支。之前阅读的 `73c5765c10` 不作为新增代码结论的基线。
参考资料:
- [原设计](https://github.com/QwenLM/qwen-code/blob/6a69c0b5bbf1232644a297b567bb36350eb58ff4/docs/plans/2026-09-06-multi-agent-board-collaboration.md),先读 §0.2;已有身份、任务、会话和投递基础可复用,历史运行证据不自动适用于新协议。
- [当前差距计划](https://github.com/QwenLM/qwen-code/blob/6a69c0b5bbf1232644a297b567bb36350eb58ff4/docs/plans/2026-09-08-workspace-agents-vs-multica-gap-and-plan.md):顶层 ACP 会话按 `(agent, thread)` 隔离;Host H1 有注册和心跳,H2/H3 不是已实现的远程服务。
- 用户提供的《CoCo bot-start 脚本与 aone-channel 远程 Agent 架构分析 — 会话记录》(`coco-aone-channel-session.md`):本轮完整阅读,但没有独立复核其私有源码包或重做安装。仅吸收架构观察,不复制内部地址、凭证、安装脚本或供应商专用命令。
- Multica 固定版本 `7a438bd5b8bf39afd54259a7eb0971390e50a8ef`:[runtime 存储](https://github.com/multica-ai/multica/blob/7a438bd5b8bf39afd54259a7eb0971390e50a8ef/server/migrations/004_agent_runtime_loop.up.sql)、[任务结构的 PriorSessionID](https://github.com/multica-ai/multica/blob/7a438bd5b8bf39afd54259a7eb0971390e50a8ef/server/internal/daemon/types.go)、[Qwen 执行适配](https://github.com/multica-ai/multica/blob/7a438bd5b8bf39afd54259a7eb0971390e50a8ef/server/pkg/agent/qwen.go)。持久身份、任务延续和执行进程生命周期应分开。
- [A2A 规范](https://a2a-protocol.org/latest/specification/)与[Codex App Server](https://learn.chatgpt.com/docs/app-server):官方页面于本轮讨论核对。具体版本、传输绑定和可选能力须在实施前冻结,不把滚动文档当成固定兼容承诺。
### 1.1 从 CoCo 调研吸收什么
值得采用:宿主主动出站领任务;常驻接入服务管理多个身份;CLI 按任务启动或恢复;任务与会话 ID 显式关联;接单、执行、结果回传分开。它说明内网执行端不必开放入站端口,长轮询也可以形成持续协作。
不能直接采用:默认全权限执行、默认禁止所有提问、恢复失败后无提示地新开会话、结果发送失败不重试。任务已确认不能代替结果已持久化,Base64 也不是凭证保护。
调研中的“A2A 必须点对点直连”“一定由模型直接对模型通信”不能作为设计前提。A2A 定义应用层语义,daemon 或网关可以代表 Agent 实现服务;私有 relay 可以承载内部投递。私有 HTTP 接口不因此自动兼容 A2A。CLI 命令样例须按实际安装版本重新确认。
## 2. 产品与所有权
| 概念 | 所有者与生命周期 |
| ----------------- | -------------------------------------------------------------- |
| Agent 定义 | 可复用角色模板,不是运行实例;继续复用现有 definition builder |
| 本地持久 Agent | 服务提供方管理身份、模型和执行策略;不随任务结束消失 |
| 外部 Agent 引用 | 调用方保存服务地址、远端身份和凭证引用;不是复制对方的模型配置 |
| Host | 可选的受管执行机器;注册 Host 不代表已获调用任意 Agent 的权利 |
| 协作任务 / thread | 创建该工作的平台负责用户目标、分工和人类验收 |
| 远端任务 | 服务提供方负责接单、容量、执行状态;调用方保存对应关系 |
| 执行会话 | 按任务隔离,可恢复;不要求每个身份永久占用进程 |
| run / 执行尝试 | 一次有起止状态的执行;同一任务会话可以承载多次 run |
外部身份用服务来源与远端 Agent ID 区分,本地 `@` 名字只是别名。服务提供方统一管理来自多个调用方的容量;调用方自己的限流不能代替它。现有调用方侧按线程树的 token 闸门不覆盖远端消耗;远端用量是否随 Task/Message 回报、是否必需,须在 P1 冻结契约时确定,否则本地预算对远端任务没有意义。
不建立新的全局身份目录或一次性设计大而全 schema。第一条外部路径落实时,再增加必要的外部引用和任务关联字段;不把已有 `runtimeId` 同时当作 Host、服务地址、Agent ID 和权限。
### 2.1 三种路径,不强迫走同一种部署
```mermaid
flowchart TB
U[原有对话入口与工作进度] <--> C[协作记录与任务路由]
C <--> L[本地 Qwen 会话适配]
C <-->|授权的 Agent 协议| E[已有外部 Agent 服务]
C <--> H[可选:受管远程 Host]
H <-->|本地原生接口| R[Qwen / Codex runtime]
X[外部授权调用方] <--> I[daemon 的 Agent 服务入口]
I <--> C
```
本地执行复用现有 ACP 路径,不为形式统一强制绕一次 HTTP。已有外部服务可以直连,无须安装我们的 Host。只有裸 runtime 才需要在其执行环境部署适配器。受管 Host 可以接受执行配置;外部服务自己拥有配置,我们只提交任务与获准分享的上下文。
创建工作不强制先创建 Team。可以选择负责人,让其委派和汇总,也可以直接指定一个 Agent。Team 是可选的内部协作机制,不是接入外部 Agent 的前提;现有 capability ceiling 尚禁止 Team 工具,不能仅凭本段宣称协作 Agent 已能启动 Team。
## 3. 通信契约与传输
外部互通优先对齐 A2A 的能力发现、消息、任务、交付物与错误语义,不先发明完整私有协议再声称兼容。管理 API 与 Agent 调用 API 分开。首次实施须选择一个明确版本和绑定,用独立客户端证明互通;不以两端都用自家客户端自测为标准兼容证据。
| 连接场景 | 首条实现方向 |
| ------------------- | ---------------------------------------------------------------- |
| 已有可达 Agent 服务 | 服务端声明的协议与认证;HTTPS 请求,支持时订阅事件,否则查询 |
| 内网受管执行端 | Host 主动向可达协调端发起 HTTPS 长轮询,回传事件;不另加公网监听 |
| runtime 原生接入 | 执行机器上的原生接口;Codex 优先评估 App Server,Qwen 复用 ACP |
出站通道是内部投递,不冒充新的 A2A 标准绑定。第一版不同时实现多套长连接、自动穿透、云中继或自动安装服务。两端都无法互相到达时,需要明确的私网或中继部署,协议不能解决网络不可达。
接单、排队、运行、等待输入、结果就绪、失败、取消分别记录。协议 Task、Codex turn 和本地人类验收不是同一终态:远端完成只说明此次远端任务完成,本地根据依赖与验收状态聚合,不能自动标整项工作 done。
关联须包含调用方、目标、远端 task/context、原生会话与执行标识。不能假设每个标准 Message 都会产生 Task,或一个 context 永远只对应一个 Task。终态后续交流按选定协议决定新任务与上下文延续,不强行复活终态对象。
### 3.1 交付与断线
- 接单先持久化,再报告已接收。幂等键以认证调用方和目标为作用域,同键不同内容明确拒绝。
- 请求超时是结果未知,不是未接收。先查原任务或按明确的幂等契约重试;未知能力的远端不盲目重发有副作用任务。
- 消息接受、模型消费、结果交付分别记录。可选能力缺失就显示未知或不支持,不制造已读回执。
- 结果先持久化,再发送并重试;事件重复去重,游标失效后回读快照。进程恢复可能重复实际副作用,不承诺 exactly-once 执行。
- 中途消息不支持时明确排到下一轮;取消收到回执后仍须确认停止。断线不自动算执行失败。
- 同一原生会话只能有明确执行所有者;不同时让本地终端和适配器无协调地写同一会话。恢复失败需报告连续性缺口,不能静默新开会话冒充恢复。
## 4. 授权不进入模型
区分三类凭证:Agent 调用凭证、Host 接入凭证、某次执行尝试的授权。现有 daemon 管理 token 不下发给外部协作者。
自管双方可采用短期一次性配对,换取可撤销、可轮换、有限期且限定 Agent 的调用凭证。外部服务遵循它声明的认证机制,不强迫使用我们的配对流程。配对是部署管理,不是自行定义一种 A2A 登录协议。
每次提交、读取、追加消息、取消、下载交付物及事件订阅都检查调用方、Agent、任务归属和当前权限。只有显式授权共享的任务才跨调用方可见。权限判断来自服务端,不信任 prompt、名字、远端自称的作者或调用方传来的本地 runId。
密钥由 daemon/适配器持有,不进 prompt、模型工具参数、公开日志或网页可读存储;提供方模型登录状态留在提供方。网络访问验证服务端身份,非 loopback 使用 HTTPS。服务地址由有权的用户配置,不能让模型任意指定 URL 携带凭证访问;重定向、交付物下载与地址变更重复检查目标,不向其他 origin 转发凭证。
外部调用在提供方明确的执行范围内运行,不自动继承提供方用户的全部工具权限。敏感动作需提供方批准,若无法约束则不开放。原 v1 的只读限制继续有效,不能因接入网络就删除;扩展到特定测试环境或工具需要单独的权限验收。
撤销禁止后续请求并关闭订阅;停止已有任务是独立操作。拒绝审批应返回明确状态,不能改成跳过审批。A 调 B、B 调 C 不自动转发 A 的凭证或全部材料,跨 hop 委派需独立授权。
受管 Host 的回传和工具写入,还要在权威任务存储事务内验证当前 run/attempt 和执行所有权。服务型远端只回传其有权报告的远端事实,由调用方映射,不能写本地任意 `thread_*`。不通过网络共享本地 JSON 文件。
当前本地 run 帧的信任边界由三处共同维持:bridge 对所有调用方剥离 `qwen.daemon.agentRun`、只从 daemon 请求上下文重注入、mid-turn 队列仅在没有 originatorClientId 时透传。最后一条是对调用方标识的否定检查,今天成立但此前未在任何契约中声明;一条恰好带上 originatorClientId 的 daemon 内部路径会静默丢帧。外部提交的任务映射到本地 run 帧不得经过 `_meta`,P1 须为此定义独立通道。
## 5. Codex 与其他 runtime
Codex 适配器放在 Codex 执行机器上,负责本地任务映射到 `thread/start`、`thread/resume`、`turn/start`、`turn/steer`、`turn/interrupt` 和事件。按实际安装版本协商支持范围,不能把 App Server 全部管理能力直接暴露给调用方。
第一条异构路径先做到接活、续聊、结果和审批。主动委派是下一档:通过 runtime 支持的受限协作工具接入,不要求它原生理解六个 `thread_*`,也不从正文随意解析 `@` 触发远端动作。适配器不是另一个负责推理的 Qwen Agent。
原生 turn 结束映射为一次执行结束;只有明确任务结果才进入结果就绪。没有证据时显示未明确收尾,不能凭自然语言猜成功。本地路径已按此执行:run 在没有调用任何收尾工具的情况下到达 completed,会被记为 `unclosed` 而不是隐式成功(`run-lifecycle.ts`)。Codex 没有六个 `thread_*`,因此每一个 Codex turn 都以这种方式结束;若不定义“什么信号算明确任务结果”,所有 Codex 任务将永远停在未明确收尾、每一项都要人手动标记。建议(待 P3 前确认):turn 结束且适配器收到一个结构化结果项(交付物或明确的完成事件)才映射为结果就绪;仅 turn 结束映射为 `unclosed`,与本地一致。
## 6. 实验开关与关闭契约
**Agent Team 与跨 Agent 协作分别 opt-in,默认关闭,互不隐式开启。** 沿用现有 `experimental.agentTeam` 及其显式环境变量入口;新的协作开关暂称 `experimental.agentCollaboration`,名称尚未落代码。缺失新设置视为 false。resolved off 是完成现有配置优先级计算后的关闭状态,不是忽略显式环境变量。
允许协作也不等于允许外部访问:开放某个 Agent、信任某个连接、注册 Host 仍需操作者显式配置。仓库提供的未受信设置、远端请求或模型不能自行打开外部授权。daemon 限制是上限,各 workspace 和会话只可缩小范围;不能错误回退到 primary workspace。
| Team | 协作 | 允许出现的增量能力 |
| ---- | ---- | ------------------------------------------------------------ |
| 关 | 关 | 原有聊天、subagent、后台消息等基线能力不变;没有新增协作感知 |
| 开 | 关 | Team 工具与实际团队上下文;没有持久协作、Host 或外部接入 |
| 关 | 开 | 持久 Agent 与授权协作;不注入 Team 工具或组员 |
| 开 | 开 | 按会话身份和权限分别暴露,不把全部团队与远端名单注入每次请求 |
关闭必须在功能边界生效,而不是只隐藏导航或等待工具运行后再报错:
1. 不追加协作 system prompt、角色帧、名单或接入工具说明;不因关闭功能改变原有工具 schema 和 prompt cache 输入。
2. 不注册或让工具发现检索到新增协作工具;调用守卫也拒绝陈旧/伪造入口。普通 subagent 不因 `forSubAgent` 就获得 `thread_*`。
3. 不读取或迁移协作存储,不启动 dispatcher、恢复扫描、Host 心跳、claim、结果发送或通知消费;仅旧文件存在不触发自动开启。
4. 不接受协作/Host/A2A 路由操作,不发布开放的 Agent Card;不影响已有普通 daemon API。静态 capability 可以报告 disabled,不初始化服务。
5. 不增加协作导航、接收人补全、自动路由或后台请求;原有 `/agents` 模板管理和 subagent 消息工具不随新开关消失。
6. 不对普通会话套用协作专用只读限制、恢复逻辑或终态契约。共享基础修复可继续复用,但必须检查原有消费者。
开启是允许使用,不是向所有模型广播全局 roster。发起委派的会话只看到授权可寻址对象;执行会话只看到当前任务需要的上下文。`sourceType: agent` 本身不是授权证明;标识可以用于分类,启用状态与服务器绑定才决定特权路径。“服务器绑定”指该会话确由 daemon 的调度器派发——存储里存在一条已认领的 run,其 `sessionId` 就是这个会话——而不是任何客户端在建会话时自称。此条件目前没有执行点:`routes/session.ts` 只拒绝客户端传入 `agent-host` 类型,`agent` 类型直接放行;`acpAgent.ts` 应用 persona 前只核对 roster 里有此 agent,不核对派发记录。结果是今天任何能访问 daemon 的客户端都能凭 `sourceType: agent` 拿到某个 Agent 的 persona 与只读工具面。P0 须补上:会话创建/恢复时按 `sessionId` 反查已认领的 run,查不到即拒绝以协作身份启动。
第一版开关在启动时解析,变更要求重启,不设计隐蔽的热切换。“启动时”须指 daemon 启动:路由注册与恢复循环在 daemon 启动时解析一次;会话级的工具注册与 persona 读取 daemon 已解析的值,不各自重读设置——否则 daemon 起来后改一次设置,会出现路由已注册而新会话无工具、或反之的分裂状态。现有 `agentTeamEnabled` 是按会话构造 `Config` 时读取的,新开关不照它的这一点。停用前提示有活跃本地或远端任务,需要显式排空或取消;重启关闭后保留记录但不自动续跑,不谎称远端已经停止。重新启用先核对原任务状态,不能新派一份。凭证撤销与关闭功能不是同一个操作。
“不能新派一份”与现有恢复逻辑直接冲突,需要一个明确的搁浅状态。现状:dispatcher 每次 tick 对每个 `running` 的 run 询问运行时本体是否还在;本体缺席且 `attempts < 2` 就重排队再启动,这是崩溃恢复。开关关闭后 dispatcher 不跑,`running` 的 run 原样留在存储里;重开时 dispatcher 看到它、发现本体已随重启消失,就会按崩溃处理——自动重派。恢复逻辑无法区分“崩溃”与“被开关搁浅”,只靠“状态未知”标签挡不住它。决定:关闭生效时(含停用前排空未完成、daemon 异常退出的情况),把 `running` / `finishing` 的本地 run 置为独立的搁浅终态并记录原因;重开后它们出现在 UI 里等人处置——由人决定作为新 run 重派还是取消,系统不自动做。远端任务同理,但先在 P2 定义。
开关关闭后恢复 `sourceType: agent` 的已有会话,决定:拒绝以协作身份恢复,按失败关闭处理,不降级为普通会话。一个顶着 Agent 名字、却没有 persona 和工具的会话,比明确拒绝更具误导性;而降级还会让“不对普通会话套用协作逻辑”这条契约在边界上变得模糊。
“无影响”指可观察的原有行为与模型输入不增加协作副作用,不保证代码零改动或绝对零性能开销。源码读取不能证明该契约,需要计划中的关闭对照观测。
## 7. 当前明确的实现差距
前六行在 `6a69c0b5bbf1232644a297b567bb36350eb58ff4` 读取;其后各行在同一分支稍后的 head 上核对,中间只有文档提交、生产代码未变,结论对 `6a69c0b5bb` 同样成立。均为源码阅读,不是实际运行结论:
| 位置与证据 | 下一步处理 |
| -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `config.ts:isAgentTeamEnabled`、`settingsSchema.ts:experimental.agentTeam` 已有默认 false;Team 工具注册有条件 | 保留开关和现有兼容性;不能据此声称整个 mesh 已 opt-in |
| `config.ts:createToolRegistry` 的 `forSubAgent | | sessionSourceType === 'agent'` 分支注册六个线程工具,没有独立协作开关 | 缩为已启用且服务器绑定的协作执行上下文;验证普通 subagent 最终工具声明,不只检查调用是否被拒 |
| `server.ts` 直接调用 `registerWorkspaceAgentRoutes`,其中立即 `recover()` 并创建 5 秒定时器 | 功能关闭时不创建扫描器;enabled workspace 的筛选必须在读协作存储前 |
| `server.ts` 直接注册 Host enrollment/heartbeat 路由 | 关闭时不提供该入口;注册自身有认证并不等于满足实验开关 |
| `session-dispatch-port.ts` 对非 local runtime 返回 unavailable | 保留为本地实现;新增明确的服务型接入,不将未知远端退回本地 |
| H2 假设主端提供 persona/tool snapshot | 仅适用受管 Host;已有服务拥有自己配置,不按 H2 重建它 |
| `config.ts` 另一处按 `sessionSourceType === 'agent'` 分支 | 与 `createToolRegistry` 同一开关,不留第二个真值来源 |
| `acpAgent.ts` 在 `sourceType: agent` 会话创建/恢复时应用 persona | 开关关闭时拒绝以协作身份恢复(§6 决定) |
| `Session.ts` + `agent-run-meta.ts` 从 `_meta` 建立 run 帧 | 开关关闭时不建立帧;帧缺席时六个 `thread_*` 本已拒绝,此处是双保险不是唯一防线 |
| `bridge.ts` 剥离/重注入 `agentRun`,mid-turn 队列按 `!originatorClientId` 透传 | 机制保留并写入 §4;P1 为外部任务另定通道 |
| `session-dispatch-port.ts` 向每个派发注入 `agentRun` | 开关关闭时 port 与 host owner 都不创建,而非创建后再拒绝 |
| `acpAgent.ts` 以 Agent 名为会话命名 | 随会话创建被拒一并消失;不单独加开关 |
| web-shell `App.tsx` 的 `'agents'` 面板、命令与导航 | 按 daemon capability 消费;关闭时不渲染入口,不只隐藏按钮 |
| `routes/session.ts` 放行客户端传入的 `sourceType: agent`;`acpAgent.ts` 不核对派发记录 | 会话创建/恢复时反查已认领的 run,查不到即拒绝(§6“服务器绑定”);这一条与开关无关,开关开着时同样必须成立 |
以上共十四处,为 P0 的检查表;此前只列四处不是全部。最后一行不受开关控制,开关开启时同样必须成立。
## 8. 替代旧计划的边界与暂不做的事
后续开发以本文件和接续计划为新方向;旧设计的存储、规则与安全约束在未被本文明确替代时继续有效。旧运行观测保留为历史证据,不删除失败记录。
| 原文位置 | 修订意图 |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 原设计 §1、§2 决策 5 | 保留持久身份与按任务隔离,不再以是否独立进程判定是否满足目标 |
| §2 决策 1–2、§10 | 只读保留为现有执行策略;双向接入与异构 runtime 不再永久排除,写权限仍未获默认授权 |
| §2 决策 4 | workspace 仍是访问与数据边界;同一外部服务可被多个 workspace 引用,不要求复制其身份 |
| §2 决策 7、§4、§5.2 | 清除混入正文的 background launcher/registry 路径;后台协调归 daemon 服务,不需要模型会话充当调度器 |
| §6 收尾工具 | 保留本地显式收尾;外部协议状态由适配器转换,不强制别的 runtime 使用 Qwen 工具名 |
| §9.1、§9.6 | 已按任务隔离后,跨任务污染与删除应重新分析共享记忆/文件及外发数据,不沿用单一跨线程 transcript 假设。此项尚未做:现有 `deleteThread` 只删线程记录,不触碰该任务的执行会话与 transcript;按任务隔离后跨线程污染由构造消除,但“删线程后其会话 transcript 归谁、何时清理、是否曾外发”仍无定义,退休 Agent 保留 transcript 的旧规则也需在此重审 |
| Web Shell 设计 §3–4 | 对话发起、进度辅助;管理入口区分创建与接入,不强迫先填任务表单或建 Team |
| 差距计划 H1–H3 | H1 可复用,H2 只管受管 Host;完整 transcript 代理不是第一条服务调用的前置条件 |
暂不做全局调度、云平台、多协议 SDK 框架、完整任务管理套件、自动穿透、每个 Agent 永久独立进程。Agent Board 的存储合并仍不在本次范围;可借其委派/回答语义,不把未认证 label 当网络身份。
尚需具体实施前确认:A2A 固定版本及必需操作、第一项外部 Agent 的执行权限、实际网络可达方式、审批接收人。原设计中父子回复、blocker 确认范围、通知目标与预算策略不因本文自动获准。

View file

@ -1,43 +0,0 @@
# Agent project and existing Host entry
[English](2026-09-11-agent-project-host-entry.md) | [简体中文](2026-09-11-agent-project-host-entry.zh-CN.md)
## Behavior
Run details now open as a thread-bound tab in the existing right panel, alongside context usage, rather than a second sidebar inside Chat. Closing that tab does not stop execution or hide streamed replies. Chat shows sending feedback immediately, then distinguishes queued, awaiting executor confirmation, starting/resuming and reported model activity until text arrives; missing telemetry is never labeled thinking. The details tab reuses the existing thread loader and run rows while mounted. This replaces the initially open embedded activity sidebar described in earlier observations below.
Thinking content is now a separate persisted `thoughtText` preview (up to 65,536 characters), accumulated from ACP `agent_thought_chunk` and Codex `item/reasoning/summaryTextDelta`. It appears using the existing chat thinking renderer and in an initially open live-run details panel; it is never merged into the final answer or synthesized when the provider supplies none. Tool activity details expand while running. Queued runs are explicitly distinguished from executing runs; offline Host bindings display the Host name and explain that no model has started. This supersedes the earlier status-only handling of thought events described below.
Execution reports cumulative reply text and activity snapshots during the turn, not only on completion. Codex reuses the existing App Server transport and its `item/agentMessage/delta` events; it does not create a subagent executor. Qwen subscribes to the existing ACP stream, filtering the bound prompt ID, for both local dispatch and managed Host execution. Thinking and tool activity are status signals, not inferred private reasoning. Snapshots are persisted at most every 500 ms and the existing chat polls every second. Host writes still require the current Host, lease and attempt; out-of-order snapshots cannot replace newer text. Reload reads the persisted snapshot. Final review messages replace the live preview by source run ID; failed or cancelled runs retain partial output. Intermediate output remains available in run details.
The live preview is capped at 262,144 characters; the final result is not shortened by that preview cap. This is a latest snapshot, not a durable log of every token event or a promise to recover unsent output after a Host crash. Fifteen seconds without activity shows waiting for output; twenty seconds without telemetry shows an unconfirmed interruption. Older records explicitly show unavailable telemetry.
Codex Host persists its native thread ID before starting the first turn, scoped by coordinator URL, workspace ID, Host ID, execution directory, Agent ID and shared thread ID. Subsequent assignments reuse the same App Server process and native thread for up to five idle minutes. After idle cleanup or worker restart they reload the mapping and use `thread/resume`, retaining native context. A corrupt mapping or failed resume fails the run instead of silently creating another conversation. This replaces per-turn process startup as well as the earlier ephemeral-per-assignment design. Existing ephemeral threads cannot be recovered through this mapping: the first assignment after upgrade creates a persistent thread with the existing shared-history frame. Ordinary one-shot subagents remain ephemeral and clean up immediately. Changing the execution Host or directory starts a separate native session; this is not cross-Host session migration.
Messages arriving during Host execution are reliably continued in the next native turn, not injected into the active turn. Before committing a successful closing result, the workspace transaction reuses the dispatcher's undelivered-trigger rebooking logic to queue a successor and redirect message outcomes. Repeating the result does not create another successor. This corrects the previous Host path which recorded coalesced messages but never delivered them. Failed/cancelled runs and already completed historical runs are not automatically replayed.
The Agent belongs to the existing sidebar project, not a separate Team. Creation displays its project name and path. Execution location is an explicit selection of the coordinator's local Qwen Code or a registered Host, with provider and execution directory. Local and remote files are not synchronized automatically. The task project selector displays a short project name and the full selected path beneath it.
The separate collaboration conversation group is superseded by one project conversation list. Root collaboration threads are adapted into view-only rows alongside ordinary sessions in their owning project, including title search. Clicking them opens shared Chat; they do not receive ordinary-session mutation actions. Storage and transcripts are not migrated. The sidebar Agent entry expands into roster, task board and execution hosts, navigating directly without a second tab strip inside the page. The session-source switch no longer carries an Agents tab: an agent session holds one run's transcript and is reachable from the run row inside its own conversation, so the switch is Tasks and Channels again.
## Existing service connection
The primary Host entry accepts an existing remote Qwen Serve URL, bearer credential, registered/trusted remote workspace directory, provider, and coordinator callback URL. The authenticated coordinator probes the remote workspace's `/agent/hosts/service` before issuing a short-lived enrollment token and calling `/agent/hosts/connect`. The remote reuses its selected runtime bridge and the existing Host pickup loop. Both local proxy and remote routes require a resolved trusted workspace and mutation authorization. Redirects are refused; HTTPS remains default outside loopback, with explicit HTTP demo opt-in. The remote bearer credential is not persisted by this feature. Initial heartbeat must succeed before connection is reported. Repeated connections in one process share one worker; provider changes are rejected.
## Limits and acceptance
2026-09-14 panel/loading acceptance: the existing named non-live `web-shell.mesh-streaming.spec.ts` browser case passed in 19.4 seconds. It holds the reply request open to verify immediate sending feedback, then verifies offline/confirmation/start/resume states, right-panel tab open/close, growing replies with the tab closed, and retained content after reload and reopening details. With no ordinary session selected, existing panel persistence does not restore the tab automatically; content is unaffected. No model run, build or full local CI was performed.
2026-09-14 warm/follow-up acceptance: the named `run-workspace-agents.mjs --host-coalesced-message` check passed all seven assertions, including rebooking, duplicate-result idempotency and final consumption. `run-codex-host-session.mjs --warm` used one real Codex PID (63591), one initialization, one thread start and two turn starts; the second turn recalled the first turn's random nonce without replaying history. Rounds took 10.550/3.603 seconds; stream callbacks stayed isolated and final cleanup exited the process. A live Host test sent its second message at 1.074 seconds while the first run was running: it initially coalesced, then automatically executed as a successor. Both runs completed at 24.026/26.223 seconds with one formal reply each and the same native mapping. The live test missed the second PID detail because snapshot sampling overwrote it, so it does not independently prove process reuse; the transport test above provides that evidence. The first live script exited 1 on that observation assertion; it now reports the gap, but was not model-rerun. A separate read-only browser check confirmed two replies before and after refresh. The `scripts/audit/` scripts named here have since been removed, so these runs are a record rather than a reproducible check. No build or local CI was run.
2026-09-14 native continuation acceptance: `scripts/audit/run-codex-host-session.mjs` ran the production transport and mapping with real Codex. Round one used `thread/start` (8.156 seconds); a separate worker and App Server process used `thread/resume` (6.454 seconds) and recalled the first round's random nonce without replaying it or any history, and without tools. Both native IDs matched, both processes exited, and a third worker reloaded the same mapping. Agent/thread scope isolation and corrupt-mapping refusal passed. Resuming a nonexistent ID failed in 798 ms with no fallback `thread/start` or model turn and no mapping change. That script has since been removed from `scripts/audit/`. The named `web-shell.mesh-streaming.spec.ts` non-live browser case passed in 14.8 seconds, including the continuing-session indicator. No build or local CI was run.
2026-09-14 real local Qwen evidence: 345 thought chunks and 138 reply chunks all matched the active prompt ID; the browser showed partial reply text before completion and 2,699 characters were persisted. That first live test incorrectly prohibited all tools, including mandatory `thread_review`, so it ended unclosed. The reusable test now explicitly permits and requires that collaboration closing tool; a reply alone is not treated as successful hand-off.
The subsequent real Qwen run passed: 31 observed increases of persisted thought text before/during 17 reply increases; final thought/reply lengths were 3,590/2,634 characters, with `completed/review` and exactly one formal summary. Browser screenshots confirmed thoughts visible in the initially open live panel before reply text appeared. The enhanced deterministic browser case passed in 13 seconds. The named ACP observer unit test was blocked by missing prebuilt package dependencies; no build or full CI was run. Codex reasoning-summary support is wired but was not model-tested in this round.
Observed on 2026-09-13 with real Codex and an isolated Host/store: ten disk-read snapshots grew from 28 characters at 30.569 seconds to 1,564 at 34.963 seconds while the run was still running and had no final summary. Result submission produced exactly one final summary. A fresh process retained the text; older sequence and attempt writes did not overwrite it. The ACP observer was checked with synthetic events, not a real Qwen model. Chrome automation was unavailable, so visual acceptance remains pending. The local demo API still returned all 38 existing threads after restart.
This is process-lifetime attachment, not an OS service installer or automatic reconnect configuration after daemon restart. Older daemons must upgrade and enable collaboration. Provider detection confirms the Codex executable, not login or model availability. Host execution retains the current sequential pickup behavior; this change does not promise parallel execution or adoption of open desktop sessions. Remote directory registration/trust is not created implicitly. Verify collapse persistence and explicit project/location UI in Chrome, then connect two running daemons without Host CLI startup flags and observe confirmation and heartbeat. Cross-machine reachability is now verified by the acceptance below; loopback checks on a single machine are not treated as cross-machine evidence.
2026-09-14 cross-machine attachment and dispatch acceptance: a real remote machine (a DataWorks sandbox instance, hostname 3ce3b771edb04c7fbede28f6049800ef-d9c5s, x86_64 Linux; the coordinator is arm64 macOS) attached as an execution host and completed one cross-machine dispatch. The remote ran a mesh serve started from this worktree's source via `scripts/dev.js` on 127.0.0.1:34777; the platform's pre-existing 0.23.2-dataworks.0 daemon was neither upgraded nor restarted — PID 333161 stayed up throughout, 3 days 10 hours at the time of acceptance. The two sides met through a self-hosted tunnel: the remote dials a relay on the coordinator side and the coordinator dials the remote serve, so the coordinator's probe of http://127.0.0.1:34777 and the remote's callback to http://127.0.0.1:4171 both hold, each with `allowHttp` explicitly enabled. `POST /agent/hosts/remote-connect` returned `{"connected":true,"workspaceCwd":"/root/mesh-serve/ws","provider":"qwen"}`; the remote wrote its credential file (hostId host_0ec6bca1-24a5-401e-8edb-47ccc3728ec6) and logged `connected as Agent Host host_0ec6bca1-24a5-401e-8edb-47ccc3728ec6 for 5ebfd480c63436d0.`. On the dispatch side a read-only agent `pod-worker` was created with `execution` managed-host and `hostIds` containing only that remote host; the coordinator's acceptance of that binding is what proves the host is in its registry. The shared thread th_31828fc7-144f-4126-9854-8b7ad87ce741 assigned to it produced run rn_251706d6-1df6-4f7a-88c6-fc06b5be0988 with status `completed`, and the thread moved to `in_review`. The reply authored by `pod-worker` reproduced line by line a file that exists only on the remote machine (/root/mesh-serve/ws/mesh-proof.txt), whose token POD-REL-99L1I9T7 never left the remote before that run, so its arrival at the coordinator proves the execution really happened remotely; the remote log records `Agent Host host_0ec6bca1-24a5-401e-8edb-47ccc3728ec6 running pod-worker on th_31828fc7-144f-4126-9854-8b7ad87ce741.`. One `Agent Host pickup failed: fetch failed` appeared on the remote before that pickup; the retry succeeded, and no effect on the pre-existing daemon was observed.

View file

@ -1,43 +0,0 @@
# 智能体项目归属与已有主机服务接入
[English](2026-09-11-agent-project-host-entry.md) | [简体中文](2026-09-11-agent-project-host-entry.zh-CN.md)
## 行为
运行详情现在作为绑定线程的 Tab 打开在现有右侧面板中,与上下文使用情况并列,不再在 Chat 内增加第二条侧栏。关闭 Tab 不会停止执行,也不影响回复流式显示。聊天区发送时立即反馈,随后区分排队、等待执行端确认、启动/续接和实际上报的模型活动,直到出现文本;无上报时不标为思考。详情 Tab 挂载时复用现有线程加载和运行记录组件。本段替代下文早期观测中默认打开的内嵌活动侧栏。
思考内容现在独立累计并保存为 `thoughtText` 预览(上限 65,536 字符),来源为 ACP 的 `agent_thought_chunk` 和 Codex 的 `item/reasoning/summaryTextDelta`。复用聊天思考组件,并在执行期间默认展开运行详情中的思考内容;不混入最终回答,供应方未提供时不编造。工具活动详情在执行期间展开。排队与执行中明确区分,绑定主机离线时显示主机名称,并解释模型尚未启动。本段替代下文早期版本仅把 thought 事件转成状态标签的做法。
执行期间持续上报累计回复正文和活动快照,不再只在结束时回传。Codex 复用现有 App Server 传输层及 `item/agentMessage/delta` 事件,不创建 subagent 执行器。Qwen 在本地派发和受管 Host 两条路径上订阅已有 ACP 流,只消费绑定的 prompt ID。思考与工具活动只表示执行状态,不推测隐藏推理。快照至多每 500 毫秒持久化一次,沿用聊天每秒刷新。Host 写入仍校验当前 Host、租约及 attempt;倒序快照不能覆盖较新正文。刷新读取已保存快照。正式 review 消息按来源 run ID 替换实时预览,失败或取消保留部分输出;中间输出仍可在运行详情中查看。
实时预览上限为 262,144 字符,最终结果不因该预览上限而截短。这是最近快照,不是逐 token 事件日志,也不保证恢复 Host 崩溃时尚未发送的内容。十五秒无新活动显示等待输出,二十秒无过程上报显示连接中断待确认。旧记录明确显示暂无过程上报。
Codex Host 在首轮开始前持久化原生 thread ID,以协调端地址、工作区 ID、Host ID、执行目录、智能体 ID 和共享线程 ID 共同隔离。后续接单复用同一 App Server 进程和原生线程,最多保留五分钟空闲时间。空闲清理或 worker 重启后重新读取映射,通过 `thread/resume` 保留原生上下文。映射损坏或恢复失败时本轮明确失败,不静默另开对话。这替代了每轮启动进程以及更早的每次接单创建临时会话的设计。旧临时会话无法通过该映射恢复:升级后首次接单携带已有共享历史帧创建持久会话。普通一次性 subagent 仍使用临时会话并立即清理进程。更换执行主机或目录会建立独立原生会话,不包含跨主机会话迁移。
Host 执行期间追加的消息可靠续接到下一原生轮次,不注入正在执行的轮次。在提交成功收尾结果之前,工作区事务复用派发器的未送达触发消息补排逻辑,创建后继执行并更新消息路由结果。结果重复提交不会重复创建后继执行。这修正了此前 Host 只记录 coalesce 消息却不投递的缺陷。失败/取消的执行及历史上已经完成的执行不会自动重放。
智能体归属侧边栏已有项目,不另建 Team。创建页显示项目名和目录。运行位置明确选择协调端本机 Qwen Code 或已接入主机,并展示执行程序和执行目录。本地与远程文件不会自动同步。任务项目选择器显示简短项目名,下方显示选中项目完整路径。
取消独立的协作对话分组,统一到项目会话列表。协作根线程适配为只读列表项,与所属项目的普通会话一起展示,支持标题搜索。点击打开共享 Chat,不套用普通会话的修改操作;不迁移存储或聊天记录。侧边栏智能体入口展开为智能体列表、任务看板和执行主机,直接进入目标页面,去掉页面内重复的标签导航。会话来源切换不再包含智能体标签:智能体会话承载单次执行的会话记录,可从所属对话的运行行进入,切换器只保留任务与频道。
## 已有服务接入
主机主要入口接收已有远程 Qwen Serve 地址、Bearer 凭证、已注册且可信的远程工作区目录、执行程序和协调端回连地址。经过认证的协调端先探测远程工作区的 `/agent/hosts/service`,再签发短期注册凭据并调用 `/agent/hosts/connect`。远程复用选中 runtime 的 bridge 和已有 Host 取单循环。本地代理与远程接口都要求明确解析的可信工作区及写入授权。拒绝重定向;回环地址之外默认 HTTPS,HTTP 演示需显式启用。此功能不持久化远程 Bearer 凭证。首次心跳必须成功才回报连接成功。同进程重复连接复用一个 worker,拒绝更换执行程序。
## 限制与验收
2026-09-14 面板与加载验收:现有 `web-shell.mesh-streaming.spec.ts` 指定非 live 浏览器用例耗时 19.4 秒通过。用例保持回复请求未完成,确认立即显示发送反馈,再验证离线/等待确认/启动/续接状态、右侧 Tab 开关、关闭 Tab 后正文继续增长,以及刷新并重新打开详情后内容保留。未选择普通会话时,现有面板持久化不会自动恢复 Tab,不影响内容。未调用模型、构建或运行全量本地 CI。
2026-09-14 进程复用与插话验收:指定 `run-workspace-agents.mjs --host-coalesced-message` 检查的七条断言全部通过,覆盖补排、结果重传幂等和最终消费。`run-codex-host-session.mjs --warm` 使用真实 Codex 同一 PID(63591),只初始化一次、创建一次线程、开始两次轮次;第二轮不重发历史,准确答出第一轮随机口令。两轮耗时 10.550/3.603 秒,流式回调保持隔离,最后清理退出进程。真实 Host 测试在首轮 running 时于第 1.074 秒发送第二条消息:先 coalesce,随后自动作为后继执行。两轮在第 24.026/26.223 秒完成,各有一条正式回复,原生会话映射相同。第二轮 PID 详情被快照采样覆盖,真实 Host 测试未单独证明进程复用,该项证据来自前述传输层测试。首次 live 脚本因该观测断言退出 1,现改为明确报告缺口,但未重新调用模型。另一次只读浏览器检查确认刷新前后均有两条回复。此处提到的 `scripts/audit/` 脚本后已移除,以上为当时的验收记录,不再可复跑。未运行构建或本地 CI。
2026-09-14 原生续接验收:`scripts/audit/run-codex-host-session.mjs` 使用生产传输层和映射,调用真实 Codex。首轮 `thread/start` 耗时 8.156 秒;另一 worker 和 App Server 进程通过 `thread/resume` 续接,耗时 6.454 秒,不重发口令或历史、不调用工具,准确答出首轮随机口令。两轮原生 ID 一致,两进程均退出,第三个 worker 重读映射仍一致。不同智能体/线程隔离、损坏映射拒绝均通过。不存在的 ID 在 798 毫秒内恢复失败,没有退回 `thread/start`、没有启动模型轮次、没有修改映射。该脚本后已从 `scripts/audit/` 移除。指定 `web-shell.mesh-streaming.spec.ts` 非 live 浏览器用例耗时 14.8 秒通过,包含继续会话状态展示。未运行构建或本地 CI。
2026-09-14 真实本地 Qwen 观测:345 个思考 chunk 和 138 个正文 chunk 全部匹配本轮 prompt ID;浏览器在结束前显示了部分正文,最终持久化 2,699 字符。首次 live 测试误禁止了所有工具,连必须调用的 `thread_review` 也被禁止,因此以 unclosed 结束。可复跑脚本已明确允许并要求该协作收尾工具;不能只凭模型有回复就算成功交回验收。
修正后的真实 Qwen 验收通过:观测到保存的思考文本增长 31 次、回复正文增长 17 次,最终分别为 3,590/2,634 字符,以 `completed/review` 结束且恰好一条正式 summary。浏览器截图确认正文出现前,默认展开的运行面板已显示思考内容。增强后的确定性浏览器用例耗时 13 秒通过。指定 ACP 观察器单测因缺少预构建包被 guard 阻止,未执行构建或全量 CI。本轮接好了 Codex 思考摘要接口,但未用真实 Codex 模型验证该新增部分。
2026-09-13 使用真实 Codex、隔离 Host 与存储观测:run 仍在 running 且没有正式 summary 时,10 次磁盘重读的正文从 30.569 秒的 28 字符增长到 34.963 秒的 1,564 字符。提交结果后恰好一条正式 summary;新进程重读仍保留正文,旧 sequence 与旧 attempt 均未覆盖新内容。ACP 观察器使用模拟事件检查,未运行真实 Qwen 模型。Chrome 自动化连接不可用,视觉验收仍待完成。本地演示 API 重启后仍返回原有 38 个对话。
这是服务进程生命周期内的接入,不是操作系统服务安装器,也不包含 daemon 重启后的自动回连配置。旧服务需要升级并启用协作。执行程序探测只确认 Codex 可执行文件,不确认登录或模型可用。Host 保留当前串行取单方式;此次改动不承诺并行执行,也不接管已打开的桌面会话。不隐式注册或授权远程目录。验收在 Chrome 确认折叠记忆、项目与运行位置展示,再连接两个未带 Host 启动参数的运行中 daemon,观察确认与心跳。跨机器连通已按下文实测验收;同一台机器上的回环测试不作为跨机器证据。
2026-09-14 跨机器接入与派发验收:真实远程主机(DataWorks 沙箱实例,主机名 3ce3b771edb04c7fbede28f6049800ef-d9c5s,x86_64 Linux;协调端为 arm64 macOS)接入为执行主机,并完成一次跨机派发。远程侧运行的是从本 worktree 源码用 `scripts/dev.js` 启动的 mesh serve(监听 127.0.0.1:34777);平台既有的 0.23.2-dataworks.0 daemon 未升级、未重启,PID 333161 在验收期间仍连续运行 3 天 10 小时。两侧经自建隧道互通:远程进程主动拨号到协调端侧中继,协调端再拨到远程 serve,因此协调端探测 http://127.0.0.1:34777 与远程回连 http://127.0.0.1:4171 均成立,且都显式启用 allowHttp。POST /agent/hosts/remote-connect 返回 {"connected":true,"workspaceCwd":"/root/mesh-serve/ws","provider":"qwen"};远程写入凭据文件(hostId host_0ec6bca1-24a5-401e-8edb-47ccc3728ec6),远程日志记录 connected as Agent Host host_0ec6bca1-24a5-401e-8edb-47ccc3728ec6 for 5ebfd480c63436d0.。派发侧新建只读智能体 pod-worker,其 execution 为 managed-host 且 hostIds 仅含该远程主机;协调端接受该绑定即证明主机已在其注册表中。向它指派的共享线程 th_31828fc7-144f-4126-9854-8b7ad87ce741 产生执行 rn_251706d6-1df6-4f7a-88c6-fc06b5be0988,状态 completed,线程随后进入 in_review。由 pod-worker 署名的回复逐行复现了仅存在于远程的文件 /root/mesh-serve/ws/mesh-proof.txt,其中口令 POD-REL-99L1I9T7 在该次执行前从未离开远程主机,因此它出现在协调端即证明执行确实发生在远程;远程日志同时记录 Agent Host host_0ec6bca1-24a5-401e-8edb-47ccc3728ec6 running pod-worker on th_31828fc7-144f-4126-9854-8b7ad87ce741.。远程侧在该次取单之前出现过一次 Agent Host pickup failed: fetch failed,重试后取单成功;未观察到对既有 daemon 的影响。

View file

@ -571,7 +571,7 @@ operator diagnostic snapshot documented below.
| Tag | Advertised when … |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_collaboration_v1` | the daemon opted into persistent workspace Agent collaboration via `experimental.agentCollaboration` (or `QWEN_CODE_ENABLE_AGENT_COLLABORATION=1`), resolved once at startup. Present means `/workspaces/:workspace/agent/*` and the Agent Host transport routes are mounted; absent means they were never registered, so a client must drop the collaboration entry point rather than render it and let the calls 404. Changing the setting requires a daemon restart. Distinct from the unconditional `workspace_agents` tag, which is subagent-definition CRUD. |
| `agent_collaboration_v1` | the daemon opted into persistent workspace Agent collaboration via `experimental.agentCollaboration` (or `QWEN_CODE_ENABLE_AGENT_COLLABORATION=1`), resolved once at startup. Present means `/workspaces/:workspace/agent/*` is mounted; absent means it was never registered, so a client must drop the collaboration entry point rather than render it and let the calls 404. Changing the setting requires a daemon restart. Distinct from the unconditional `workspace_agents` tag, which is subagent-definition CRUD. |
| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every normal API route, including `/health` on loopback binds; channel webhook ingress keeps its independent shared-secret authentication, and Web Shell document and asset routes remain pre-auth. |
| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. |
| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. |

View file

@ -18,8 +18,6 @@ of expertise. This changes the previous display-only description contract.
本入口暂不支持附件。成员关系来自工作区名单,不要求额外建立 Team;
提示词中的成员职责简介只帮助选择协作者,不授予权限,也不保证其能力。
> **Development handoff (2026-09-09):** Read the [Agent service architecture](../design/2026-09-09-agent-service-collaboration.md) and [successor implementation plan](./2026-09-09-agent-service-collaboration-plan.md) before continuing. Start at P0: independent default-off experimental gates. The successor architecture §8 explicitly identifies superseded decisions; other storage and safety contracts below remain applicable. Historical runtime observations are not evidence that the new service boundary is implemented. Do not resume the old §5.2 sequence by default.
> Current session-adapter caveat (2026-09-08): the earlier live demo below does
> not verify the replacement ACP-session execution path. Dispatch now prepares
> and binds a session before asynchronously activating its prompt. Live replies
@ -184,29 +182,11 @@ ordinary conversation appeared in the existing Agents session list as
sessions remain untouched. This is local-daemon product-path evidence, not a
claim of a Multica-compatible remote Runtime.
**Verified through the existing local Runtime owner (2026-09-08).** Before the
registered-Host slice, the Agent surface projected Qwen Code's selected
`WorkspaceRuntime`, the workspace's durable
`hostSessionId`, and the bridge's real heartbeat. Restarting the daemon kept
host session `f210855f-45ab-4624-a858-bf11785e22d0`; the non-empty roster
restored its owner without a task mutation, and the displayed heartbeat moved
from 20:29:10 to 20:29:19. The Runtime view also showed provider, Agent/session
counts, and running/queued task counts. A binding unknown to this daemon is
reported offline and the dispatcher leaves its work queued as
`runtime_unavailable`; it is not converted into a terminal launch failure.
This proves the one local host and its restart continuity, not remote
registration or placement.
**Verified through registered-Host H1 (2026-09-08).** The primary daemon now
owns a workspace-scoped Host registry. A ten-minute one-time credential was
exchanged by a second `qwen serve` process for Host
`host_d43cad67-c491-4915-9186-481732a0458e`; replaying the enrollment returned 401. Its provider and workspace advertisement appeared beside the local daemon
in the Runtime view. Stopping it for more than the 15-second liveness window
changed the stored Host to offline. Restarting it without the enrollment token
restored the same Host id, and restarting the primary daemon while it was alive
produced a heartbeat failure followed by automatic reconnection with that same
id. This proves registration and liveness only: H1 does not permit binding an
Agent to that Host or executing a run there.
**Verified through the existing local host owner (2026-09-08).** The
workspace's durable `hostSessionId` survived a daemon restart: host session
`f210855f-45ab-4624-a858-bf11785e22d0` was kept, and the non-empty roster
restored its owner without a task mutation. This proves the one local host and
its restart continuity, not remote execution or placement.
An earlier browser run exposed a prompt-level ping-pong: Bob and Alice used
peer mentions in result prose, and each mention correctly booked another run.
@ -353,10 +333,7 @@ process, so a process failure affects every local agent session.
The cost is N live session contexts and model clients inside that process. A
local roster is expected to stay small — two to five agents — and the machine's
memory is the practical limit. The registered-Host H1 slice adds durable
identity, advertisement and daemon heartbeat, but remote Agent execution and
per-agent process boundaries still require the H2 run protocol; this demo does
not claim those capabilities.
memory is the practical limit.
Everything the execution layer needs still exists:
@ -422,15 +399,10 @@ read-only; a private server or trusted-looking name is not evidence.
AgentWorkspaceState schemaVersion, workspaceId, hostSessionId,
nextRunSequence
AgentAgentsFile schemaVersion, agents[]
AgentHostsFile schemaVersion, hosts[], enrollment?
AgentHost id, name, secretHash, workspaceCwd, providers[],
createdAt, lastSeenAt
WorkspaceAgent id, name, description, color, agentType, model,
instructions, queueLimit, maxConcurrentRuns,
enabled, createdAt, retiredAt,
execution: local | managed-host(hostIds[])
runtimeId ← legacy field
Thread schemaVersion, id, title, body, acceptanceCriteria,
@ -467,10 +439,7 @@ V1 declares and validates this whole shape in one storage version. The owner
chose one migration rather than serial schema bumps for fields already designed
for steps 5-8. Fields whose producers do not exist yet remain optional and do
not claim that delivery, provenance, recovery, or transcript slicing is
implemented. `hostSessionId` is a workspace singleton. An absent `execution`
is local; `managed-host` contains the exact registered Host ids allowed to
claim that Agent's work. Registration alone grants no work and `runtimeId` is
not reused as authorization. A task session id is derived from `(agent id, thread id)` and is
implemented. `hostSessionId` is a workspace singleton. A task session id is derived from `(agent id, thread id)` and is
stored on each run, so there is no ambiguous Agent-wide conversation handle.
`authorKind` is `human | agent | system`. Until the ambient producer lands in
@ -763,27 +732,24 @@ action on the child caused it.
Implemented on the single #11206 delivery branch:
| File | Responsibility |
| --------------------------------------------------------- | ------------------------------------------------------- |
| `core/src/agents/workspace-agents/types.ts` | Entities and limits |
| `core/src/agents/workspace-agents/store.ts` | Paths, validation, locking, CRUD and Host registry |
| `core/src/agents/workspace-agents/mentions.ts` | `@name` → agent ids |
| `core/src/agents/workspace-agents/dispatch-policy.ts` | `decideDispatch` — pure |
| `core/src/agents/workspace-agents/thread-actions.ts` | `postMessage` — append and book under one lock |
| `core/src/agents/workspace-agents/thread-status.ts` | Aggregate status over every run's close obligation |
| `core/src/agents/workspace-agents/run-lifecycle.ts` | Run close, terminal state, status application, outbox |
| `core/src/agents/workspace-agents/run-context.ts` | Per-turn ambient `(agent, run, thread)` binding |
| `core/src/agents/workspace-agents/prompt.ts` | Turn envelope: thread frame, delta, gap, peers |
| `core/src/agents/workspace-agents/capability.ts` | Read-only name and invocation boundary |
| `core/src/agents/workspace-agents/persona.ts` | Resolves an agent's persona for its own session |
| `core/src/tools/thread-tools.ts` | The six thread tools; ambient identity only |
| `core/src/agents/workspace-agents/dispatcher.ts` | FIFO selection, runtime entry point, parent reports |
| `cli/src/serve/workspace-agents/session-dispatch-port.ts` | The one binding to the local agent session runtime |
| `cli/src/serve/workspace-agents/agent-host-session.ts` | Hidden ACP host ownership, keepalive, reload |
| `core/src/agents/workspace-agents/host-lease.ts` | Atomic Host pickup, lease validation and result commit |
| `cli/src/serve/routes/agent-hosts.ts` | Host enrollment, heartbeat, pickup and result transport |
| `cli/src/serve/agent-host-client.ts` | Remote daemon credential and heartbeat client |
| `cli/src/acp-integration/acpAgent.ts` | Applies the persona when an agent session spawns |
| File | Responsibility |
| --------------------------------------------------------- | ----------------------------------------------------- |
| `core/src/agents/workspace-agents/types.ts` | Entities and limits |
| `core/src/agents/workspace-agents/store.ts` | Paths, validation, locking and CRUD |
| `core/src/agents/workspace-agents/mentions.ts` | `@name` → agent ids |
| `core/src/agents/workspace-agents/dispatch-policy.ts` | `decideDispatch` — pure |
| `core/src/agents/workspace-agents/thread-actions.ts` | `postMessage` — append and book under one lock |
| `core/src/agents/workspace-agents/thread-status.ts` | Aggregate status over every run's close obligation |
| `core/src/agents/workspace-agents/run-lifecycle.ts` | Run close, terminal state, status application, outbox |
| `core/src/agents/workspace-agents/run-context.ts` | Per-turn ambient `(agent, run, thread)` binding |
| `core/src/agents/workspace-agents/prompt.ts` | Turn envelope: thread frame, delta, gap, peers |
| `core/src/agents/workspace-agents/capability.ts` | Read-only name and invocation boundary |
| `core/src/agents/workspace-agents/persona.ts` | Resolves an agent's persona for its own session |
| `core/src/tools/thread-tools.ts` | The six thread tools; ambient identity only |
| `core/src/agents/workspace-agents/dispatcher.ts` | FIFO selection, runtime entry point, parent reports |
| `cli/src/serve/workspace-agents/session-dispatch-port.ts` | The one binding to the local agent session runtime |
| `cli/src/serve/workspace-agents/agent-host-session.ts` | Hidden ACP host ownership, keepalive, reload |
| `cli/src/acp-integration/acpAgent.ts` | Applies the persona when an agent session spawns |
### 5.1 Local review correction — committed and verified
@ -1127,12 +1093,7 @@ source-task attribution at every hop (`ReasonInvocationNotAllowed`,
`ReasonAttributionBlocked`). V1 here permits any agent to mention any enabled
workspace peer, but still records non-spoofable source-run provenance.
**Partially implemented.** An Agent can now be placed on an explicit set of
registered Hosts. Only those Hosts can claim its queued run; lease takeover,
late-result refusal and idempotent result commit are implemented. The execution
machine's client still has no model worker loop, so placement and transport do
not yet equal remote model execution. Cloud and non-Qwen runtimes remain the
hard gap. Scheduled and external-event triggers are absent but the cron scheduler and
Scheduled and external-event triggers are absent but the cron scheduler and
channel workers already exist to carry them. Board views, labels, search and
cross-issue references have no equivalent.
@ -1303,14 +1264,9 @@ remain genuinely open:
### Resolved during step 3
Runtime shape is settled: an Agent carries execution placement rather than being
the runtime. V1 permits local execution or an explicit list of registered Host
ids. Qwen Code's existing
`WorkspaceRuntime`, durable host-session claim and ACP bridge heartbeat are its
local implementation. H1's separate registry authenticates remote Qwen Host
daemons; H2 now lets only explicitly selected Hosts claim runs and routes result
mutations through the primary's lease-checked store. Registration still does
not mean placement, and the Host-side model worker remains unimplemented.
Runtime shape is settled: an Agent is an identity, not the runtime. Qwen Code's
existing `WorkspaceRuntime`, durable host-session claim and ACP bridge
heartbeat are its local implementation.
## 10. Out of scope

View file

@ -1,7 +1,5 @@
# Workspace agents implementation — step-by-step acceptance criteria
> **Development handoff (2026-09-09):** Future work follows the [successor plan](./2026-09-09-agent-service-collaboration-plan.md), beginning with P0, and the [Agent service architecture](../design/2026-09-09-agent-service-collaboration.md). Keep the observations below as historical evidence, not as acceptance of A2A, remote Codex, or feature-off compatibility. Delivery remains one PR, #11206; no local CI loop or new child PR is required.
> Companion to [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md) §5.2 (ten steps, as numbered on the `codex/multi-agent-mesh-foundation` branch) and [`2026-09-07-workspace-agents-review-round2-handoff.md`](./2026-09-07-workspace-agents-review-round2-handoff.md).
> Delivery shape: **one implementation and delivery PR** (#11206). Runtime changes #11200 / #11202 / #11204 are merged into its branch. Because that branch was their PR base, GitHub records them as merged draft references; their review history remains available and none is merged separately to `main`.
> Nothing in this file was executed by its author. "Evidence" means what the implementer reports, with observed values, in the PR description or a `docs/verification/workspace agents/` package.
@ -426,13 +424,12 @@ instead of leading with reusable subagent definition files. New Agent reuses the
existing manual/model-assisted builder but writes one roster identity directly;
Definitions and linking an existing definition remain secondary compatibility
paths.
The same entry now separates Agents, Tasks and Runtime into three views. Browser
HMR confirmed the view switch, task-detail return preserving the Tasks view,
the task-scoped transcript entry staying on the run rather than the Agent row,
and the local Runtime card. The already-running daemon was not restarted, so
its new `runtime: online` response and idle-without-session status were source
checked but not claimed as a browser observation. No test suite, build, lint,
typecheck or CI ran.
The same entry now separates Agents and Tasks into two views. Browser HMR
confirmed the view switch, task-detail return preserving the Tasks view, and
the task-scoped transcript entry staying on the run rather than the Agent row.
The already-running daemon was not restarted, so the idle-without-session
status was source checked but not claimed as a browser observation. No test
suite, build, lint, typecheck or CI ran.
**Product-linking observation (2026-09-08).** The primary New Agent action now
starts with model-assisted or manual creation and no longer competes with a
@ -448,16 +445,10 @@ titles, and manual-title preservation is enforced by the adapter but was not
changed in this browser pass. No local test suite, build, lint, typecheck or CI
ran.
**Local Runtime observation (2026-09-08).** The Runtime view now reads the
selected workspace Runtime, persisted host-session claim and bridge heartbeat;
it no longer fills a constant `local` card in the browser. After a daemon
restart, the non-empty roster restored the host without a task mutation and
reused host session `f210855f-45ab-4624-a858-bf11785e22d0`. The browser showed
provider `Qwen Code ACP`, three Agents, zero restored task sessions, zero
running/queued tasks, and a heartbeat advancing from 20:29:10 to 20:29:19.
Bindings not registered in this daemon read offline and remain queued with a
typed `runtime_unavailable` dispatch observation. No remote registration,
placement, build, lint, typecheck, test suite or CI was run.
**Local host observation (2026-09-08).** After a daemon restart, the non-empty
roster restored the host without a task mutation and reused host session
`f210855f-45ab-4624-a858-bf11785e22d0`. No build, lint, typecheck, test suite
or CI was run.
Tool responses now report booking as queued work rather than claiming the peer
has already started, and `thread_block` reports the durable blocked state

View file

@ -1,7 +1,5 @@
# Workspace agents in Web Shell — design direction
> **Development handoff (2026-09-09):** The [Agent service architecture](../design/2026-09-09-agent-service-collaboration.md) and [successor plan](./2026-09-09-agent-service-collaboration-plan.md) now govern product entry and experimental gating: start from existing conversations, distinguish creating an Agent from connecting an existing service, and expose coordination only when enabled. The ledger-first navigation and cross-thread transcript assumptions below are historical; retain useful status, routing, accessibility, and rendering guidance where compatible with the successor.
> For §5.2 step 9, written before any UI exists so the build does not start from a blank page or from whichever list component was nearest to hand.
> Grounded in Multica's shipped UI, read at `multica-ai/multica@7a438bd5b`: `packages/views/issues/components/{issue-detail,execution-log-section,comment-trigger-chips,thread-nav-panel}.tsx` and `packages/views/issues/blocked-trigger-copy.ts`. Where this design diverges from theirs, the reason is stated.
> Companion to [`2026-09-06-multi-agent-board-collaboration.md`](./2026-09-06-multi-agent-board-collaboration.md) and [`2026-09-07-workspace-agents-implementation-acceptance.md`](./2026-09-07-workspace-agents-implementation-acceptance.md).
@ -43,9 +41,8 @@ A chat log with a status badge is what Slack, Linear and Multica's issue view al
## 4. Surfaces
The existing Agents entry has three views: **Agents**, **Tasks**, and
**Runtime**. They share one backend snapshot but are not stacked into one long
page. This mirrors Multica's product boundaries without adding another shell
The existing Agents entry has two views: **Agents** and **Tasks**. They share
one backend snapshot but are not stacked into one long page. This mirrors Multica's product boundaries without adding another shell
navigation system.
### 4.1 Roster, inside the existing Agents page
@ -93,9 +90,10 @@ Threads
### 4.3 Runtime
The demo exposes the actual `local` binding and whether the serving daemon is
online. It does not invent remote placement, heartbeat history, or process
isolation. Those belong to a later runtime registry.
There is no Runtime view. Every Agent runs on the serving daemon, which is
always available while the page is. The demo does not invent remote placement,
heartbeat history, or process isolation. Those belong to a later runtime
registry.
### 4.4 Thread view

View file

@ -1,7 +1,5 @@
# What is missing against Multica, and how to close it
> **Development handoff (2026-09-09):** Continue with the [Agent service architecture](../design/2026-09-09-agent-service-collaboration.md) and [successor plan](./2026-09-09-agent-service-collaboration-plan.md), starting at P0 rather than automatically implementing H2. Registered execution Hosts and existing external Agent services are different integration paths. H1 and the observations below remain evidence for their stated scope; they do not prove bidirectional service interoperability or experimental-off isolation.
## Architecture correction — persistent identity, task-scoped sessions
The previous implementation score confused a working collaboration engine with
@ -30,9 +28,9 @@ definition first. Existing definitions remain a secondary linking/template path
for compatibility, not a prerequisite for a new persistent Agent.
The existing Agents navigation now opens the runnable Agent roster and shared
tasks first; reusable definition files are a secondary Definitions view.
New Agents bind explicitly to the local Runtime. An Agent with no task session
is idle while that Runtime is online; a task/run, not the Agent row, owns the
link to its ordinary conversation transcript.
New Agents run on the local daemon. An Agent with no task session is idle; a
task/run, not the Agent row, owns the link to its ordinary conversation
transcript.
## Current correction — task orchestration before process isolation
@ -378,11 +376,8 @@ it survives the change below.
**Implemented, but narrower than Multica.** Each identity owns a task-scoped
top-level ACP session with its own persona, model setting and transcript. It is
no longer a background subagent, and work on another task gets another session.
The ACP bridge still multiplexes those sessions in one process. The produced
`local` binding resolves through Qwen Code's existing workspace Runtime. Its
durable host-session claim, provider, bridge heartbeat and live workload appear
in the Runtime view. H1 now also registers remote Qwen Host daemons and tracks
liveness, but placement and remote execution remain absent.
The ACP bridge still multiplexes those sessions in one process, owned by Qwen
Code's existing workspace Runtime and its durable host-session claim.
**Missing entirely.** Remote placement and execution; labels and due date on the
work item; squads; inbox; projects. Priority, acceptance criteria,
@ -568,112 +563,29 @@ Stage A's initial sessionization is complete: `launcher.ts`, `dispatch-port.ts`
`dispatchAgentRuns` and the two ACP control methods behind them. Dispatch runs
in the daemon, where the sessions are. The follow-up correction scopes those
sessions to `(agent, thread)` instead of one transcript per identity.
H1 now supplies Multica-style Host registration and liveness; its remote
task-execution boundary remains absent.
Before H1, the local Runtime follow-up deliberately reused existing
infrastructure rather than adding a parallel host store. A non-empty roster restores the persisted
host-session owner after daemon restart, and the page reads the bridge heartbeat
plus live Agent/session/run counts. Unknown runtime bindings stay offline and
their work remains queued instead of failing terminally. The observed restart
reused host session `f210855f-45ab-4624-a858-bf11785e22d0`, with the heartbeat
advancing in the browser. This is one real local host; it is not the remote
Runtime registry, heartbeat transport or placement layer Multica has.
The local host follow-up deliberately reused existing infrastructure rather
than adding a parallel host store. A non-empty roster restores the persisted
host-session owner after daemon restart. The observed restart reused host
session `f210855f-45ab-4624-a858-bf11785e22d0`. This is one real local host; it
is not the remote Runtime registry, heartbeat transport or placement layer
Multica has.
### Original-goal acceptance audit, 2026-09-08
This is a bounded acceptance pass against the original request, not a
percentage estimate.
| Original requirement | Acceptance evidence on this branch | Result |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Declare persistent Agent identities | The roster survives daemon restart; a fresh no-definition Agent applied its own durable instructions and model in its task session | Passed locally |
| Agent is not a background subagent or Agent Team member | Dispatch creates a top-level ACP session with `sourceType: agent`; the workspace-agent execution path no longer uses `BackgroundTaskRegistry` or Agent Team | Passed; sessions still share one ACP daemon |
| Assign and orchestrate tasks in a panel | The Web Shell creates root tasks with assignee, priority and acceptance criteria, nests child tasks, shows aggregate status, and lets a person mark work done | Passed locally |
| Agents collaborate through a shared thread | Live runs covered peer mentions, child delegation, parent reports, waiting, blocking and review | Passed locally |
| A person can intervene while an Agent is working | A live mid-turn message was persisted in the same run's transcript and consumed window, and changed the submitted review | Passed for the normal path; late-drain crash windows remain open |
| Reuse Qwen Code conversations | Agent task sessions appear under the existing sidebar's Agents source and open in the ordinary session view; the shared task keeps only cross-Agent coordination state | Passed locally |
| Multica/Harness-style Host layer | The primary daemon persists registered Hosts; a second daemon keeps a stable Host id across both daemon restarts, advertises provider/workspace, and drives online/offline through heartbeat | H1 passed locally; no Agent binding, remote execution, placement or transcript proxy (H2/H3) |
| Full Multica tracker breadth | Agent instructions/model/concurrency and task priority/acceptance criteria exist | Partial: labels, projects, inbox, squads, due dates and invocation policy are absent |
The audit's next step, registered-Host H1, is now implemented and accepted below.
### Registered-Host feasibility gate
Source review rejects treating remote Hosts as one more `runtimeId`. Qwen
Code's existing `WorkspaceRuntime` is a live object inside one daemon: it owns
the local workspace path, filesystem, ACP bridge and session service. The
workspace-agent ledger is also local to that path, and every current dispatch,
thread tool and transcript link terminates at that same daemon. A second daemon
cannot safely execute a run merely by appearing in the Runtime list: it cannot
read the authoritative ledger, prove ambient run identity, apply `thread_*`
mutations, or expose its task session through the primary daemon's conversation
list.
There are three real product shapes:
1. A shared service owns the ledger and runtime registry, as Multica and
Harness do.
2. The primary Qwen daemon becomes that control plane, and remote Host daemons
connect outbound as execution workers. Thread mutations and session events
must be authenticated and proxied back to the primary.
3. Keep the current local-only Host and make no remote claim.
For an open-source demo without introducing a separate hosted service, option
2 is the recommended shape. Its design gate must settle the enrollment
credential, remote workspace/repository mapping, scoped run protocol,
transcript ownership, reconnect/replay contract, and whether a remote Host may
invoke only `thread_*` on the primary or a wider tool surface. Implementing a
Host card or picker before those decisions would be a false capability.
The existing code already supplies part of the transport, and should be reused:
- `@qwen-code/sdk/daemon` has a network `DaemonClient` and
`DaemonSessionClient` for capability discovery, workspace selection,
create/resume, prompt, event streaming and cancellation.
- `bearerAuth` protects a non-loopback `qwen serve`, and the capabilities plus
workspace-qualified routes already fail when a requested workspace is absent
or untrusted.
- `agentThreadSessionId` and `sourceType: agent` already define the stable
`(agent, thread)` session identity.
Those pieces do not provide a Host credential. The server bearer token grants
the whole daemon and is too broad; the channel worker's prompt authorization is
a local child-process sentinel, not a remote enrollment credential. Nor do they
make current `thread_*` tools remote: every mutation currently opens the local
JSON store and proves the ambient run there. A remote Host therefore needs a
scoped run credential and a primary-side tool endpoint that repeats the same
live-run check inside the authoritative transaction. This should be a direct
built-in transport, not MCP: enabling arbitrary MCP would silently widen the
read-only capability ceiling.
Implementation has three separately accepted slices:
1. **H1 — Host registry and heartbeat.** A one-time enrollment credential is
exchanged for a stable Host id and scoped secret; a second daemon advertises
its provider set and workspace mapping; heartbeat drives online/offline and
survives primary restart; the Runtime page shows only these stored/live
facts. No Agent can bind to the Host yet.
2. **H2 — Bound remote run.** An Agent binds to one online Host; only that Host
may claim its run; the claim carries an immutable persona/tool snapshot and
attempt-scoped credential; remote `thread_*` calls execute on the primary
and recheck the run before writing; disconnect leaves unconsumed work
replayable rather than terminally failed.
3. **H3 — Conversation continuity.** The primary session catalog records the
remote session owner and proxies its events/transcript/cancel surface, so the
existing Agents conversation list opens the same `(agent, thread)` history
before and after either daemon restarts.
H1 observation: enrollment replay returned 401; stopping the Host past the
15-second window changed it to offline; restarting it without the token restored
Host `host_d43cad67-c491-4915-9186-481732a0458e`; restarting the primary caused
one failed heartbeat followed by reconnection with that same id. The Runtime
page showed both the local daemon and `Demo-Host`, with the advertised provider,
workspace and moving heartbeat. No API or UI path can bind an Agent to it.
The registered-Host capability is accepted only after H1-H3 pass together.
Cloud scheduling, autoscaling and one permanent process per Agent remain out of
scope.
| Original requirement | Acceptance evidence on this branch | Result |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Declare persistent Agent identities | The roster survives daemon restart; a fresh no-definition Agent applied its own durable instructions and model in its task session | Passed locally |
| Agent is not a background subagent or Agent Team member | Dispatch creates a top-level ACP session with `sourceType: agent`; the workspace-agent execution path no longer uses `BackgroundTaskRegistry` or Agent Team | Passed; sessions still share one ACP daemon |
| Assign and orchestrate tasks in a panel | The Web Shell creates root tasks with assignee, priority and acceptance criteria, nests child tasks, shows aggregate status, and lets a person mark work done | Passed locally |
| Agents collaborate through a shared thread | Live runs covered peer mentions, child delegation, parent reports, waiting, blocking and review | Passed locally |
| A person can intervene while an Agent is working | A live mid-turn message was persisted in the same run's transcript and consumed window, and changed the submitted review | Passed for the normal path; late-drain crash windows remain open |
| Reuse Qwen Code conversations | Agent task sessions appear under the existing sidebar's Agents source and open in the ordinary session view; the shared task keeps only cross-Agent coordination state | Passed locally |
| Multica/Harness-style Host layer | Registered Hosts, remote placement and remote execution are not part of this PR | Out of scope for this PR |
| Full Multica tracker breadth | Agent instructions/model/concurrency and task priority/acceptance criteria exist | Partial: labels, projects, inbox, squads, due dates and invocation policy are absent |
A later source audit found that sessionization alone had not delivered the
claimed persona: persona fields were assigned after `Config.initialize()` had
@ -750,7 +662,7 @@ three kinds, and the distinction matters more than the number:
not assumed — but a single field check can be disabled with every suite
still green. That is a real if minor gap.
Retired 2026-09-09 per the successor plan §1: `run-workspace-agents-concurrency.mjs`,
Retired 2026-09-09: `run-workspace-agents-concurrency.mjs`,
`run-workspace-agents-crash.mjs` and `fuzz-workspace-agents.mjs`. Their results above
stand as recorded; the questions they answered were one-time. The orphan sweep, the
two tsconfigs and `run-workspace-agents.mjs` were removed later with the rest of
@ -797,7 +709,7 @@ question for whoever owns that surface.
**Multica 实际是什么**:Linear 形态的 issue tracker,assignee 可以是 agent,外加一层 runtime 注册。`agent_runtime` 是独立实体(workspace + daemon_id + provider,带在线状态和心跳),agent 绑定到它上面;`agent_task_queue` 是 agent × issue 的派发队列;`issue` 有优先级、7 种状态、验收标准、截止日期、标签、项目;对话就是 issue 上的 comment。页面里有独立的 runtimes 和 runtimes/[id]。
**我们的状态**:底层协作规则已经具备;每个 `(agent, task)` 都是独立的顶层 ACP session,不再是 background subagent,同一 Agent 处理不同任务也不会共用 transcript。本地 `runtimeId` 已产生、校验并在面板展示,但这些 session 仍共享一个 ACP daemon;H1 已实现远程 Host 注册和心跳,放置、远程执行和进程级故障隔离尚未实现。工作项已有优先级和验收标准;标签、截止日期、project、squad、inbox 仍未实现。
**我们的状态**:底层协作规则已经具备;每个 `(agent, task)` 都是独立的顶层 ACP session,不再是 background subagent,同一 Agent 处理不同任务也不会共用 transcript。这些 session 仍共享一个 ACP daemon;远程 Host 注册、放置、远程执行和进程级故障隔离都不在本 PR 范围内。工作项已有优先级和验收标准;标签、截止日期、project、squad、inbox 仍未实现。
**之前那个七八成错在哪**:我拿自己那份设计文档当卷子打分,而文档 §1 当时就把执行模型定错了。现在修正的是本地 demo 主链路;若按 Multica 完整产品计算,远程 Runtime、权限、项目和收件箱仍然不存在,不能再用百分比掩盖不同分母。

View file

@ -1,128 +0,0 @@
# 交接:A2A 传输层与 Host 出站取件
状态:任务 1 已完成并通过独立 Python SDK 互通;任务 2 已补齐协调端授权放置、长轮询取件、结果回传、租约接管和执行端 model worker,并完成一次真实模型闭环。上游状态见[实施计划 §3b](./2026-09-09-agent-service-collaboration-plan.md)。交付分支仍为 `codex/multi-agent-mesh-foundation`(PR #11206)。
A2A 的语义、存储与授权已落地。Host 放置采用操作者确认的最小模型:Agent 仍由 workspace store 拥有,`execution` 只区分本地与一组获准领取它的 managed Host;Host 注册本身不获得任何 Agent。没有引入 Host pool,也没有复用 `runtimeId` 承载授权。
任务 1 实测(Python `a2a-sdk==1.1.2`):公开 card 的 skills 为 0,认证 card 为 1;接单返回一个 Task,同 `messageId` 重发仍为同一 Task,列举为 1;第二调用方读取失败;同键异内容错误携带原 Task id;取消返回 `TASK_STATE_CANCELED`。`SendStreamingMessage` 返回 `-32004`,未宣传且未支持。关闭协作开关的真实 daemon app 中,公开 card 为 404,普通 `/health` 为 200。
两项文件不重叠,可并行。
---
## 任务 1:A2A JSON-RPC 传输层
把已冻结的契约和已实现的五个操作,暴露成一个真的 A2A 服务端。
### 已有(不要重写)
- `packages/core/src/agents/workspace-agents/a2a-contract.ts` — 冻结的版本与常量:`A2A_PROTOCOL_VERSION = '1.0'`、`A2A_TRANSPORT_BINDING = 'JSONRPC'`、`A2A_AGENT_CARD_PATH = '.well-known/agent-card.json'`、`A2A_CONTENT_TYPE = 'application/a2a+json'`、`QWEN_A2A_EXTENSION_URI`、`toA2ATaskState`、`externalRequestKey`。选型理由见[冻结契约](../design/2026-09-09-a2a-frozen-contract.md),**不要改版本或绑定**。
- `packages/core/src/agents/workspace-agents/a2a-server.ts` — 五个必需操作,全部已实现并测过:
```ts
a2aSendMessage(projectRoot, caller, { agentId, messageId, title, body, acceptanceCriteria? })
: Promise<A2AResult<A2ATaskView>>
a2aGetTask(projectRoot, caller, taskId): Promise<A2AResult<A2ATaskView>>
a2aListTasks(projectRoot, caller, agentId): Promise<A2AResult<A2ATaskView[]>>
a2aCancelTask(projectRoot, caller, taskId)
: Promise<A2AResult<{ task: A2ATaskView; runsStillLive: number }>>
a2aAgentCardForCaller(projectRoot, caller, agentIds, baseUrl): Promise<A2AAgentCard>
```
`A2ACaller = { callerId: string; secret: string }`。
- `a2a-grants.ts` — grant 的签发、撤销、校验、作用域、过期。
- `external-intake.ts` — 幂等接单、同键冲突、按调用方限定读写。
### 要做的
1. 把 `@a2a-js/sdk@^1.1.0` 加进 `packages/cli/package.json`(目前不是依赖)。它的 peer 里有 `@grpc/grpc-js` 和 `@bufbuild/protobuf`——**只用 `./server/express`,不要把 gRPC 拉进来**。
2. 新建 `packages/cli/src/serve/routes/a2a.ts`:用 SDK 的 express 服务端,把 `A2ARequestHandler` 的五个必需方法转成上面五个函数的调用。
3. 公开 Agent Card 路由 `GET /.well-known/agent-card.json`。
4. `A2AFailure` → JSON-RPC 错误码映射。
5. 在 `server.ts` 挂载,照 `if (agentCollaborationEnabled) { … }` 的现有两处(约 2118、3159 行)同样包起来。
### 必须守住的点(每条都有理由,不是风格偏好)
- **调用方身份只能来自传输层认证,绝不能读请求体里的 `callerId`。** 请求体里的 id 是一句自称;grant 的全部意义就是这句自称要被核对。
- **公开卡片与 `a2aAgentCardForCaller` 是两份不同文档。** 后者按调用方生成、列出它被授权的 agent。公开那份是给发现用的,**不能枚举 agent**——否则任何人 GET 一次就拿到了本 daemon 的 agent 清单。
- **`A2AFailure` 的 `refused` 必须映射成单一错误码,四种授权失败对外不可区分。** 上游已经把「无 grant / 密钥错 / 过期 / 越权」压成了一个 `refused`,传输层若再拆开就把这层设计撤销了:能区分它们的调用方可以枚举 agent。
- **`not_found` 同理**:「没有这个任务」与「不是你的任务」是同一个答案。不要为后者加 403 分支。
- **`conflict` 要把 `existingTaskId` 带给调用方。** 这是「你这个 id 已经用在别的内容上了」,与普通失败不同——分不清的调用方会永远重试。
- **`capabilities.streaming` 与 `pushNotifications` 保持 false。** 规范用这两个标志门控可选操作;advertise 一个没实现的,会把客户端的正确行为变成失败调用。因此也**不要实现** `sendMessageStream` / `resubscribe` / 四个 push 方法。
- 版本用 header `A2A-Version: 1.0`,响应 `Content-Type: application/a2a+json`。**不要在请求或卡片里带 patch 版本号**(规范明确 SHOULD NOT)。
### 过关条件
不是「能编译」「能 curl 通」。要的是:
1. 用 **`a2a-sdk`(Python,PyPI 1.1.2)** 作为独立客户端跑通接单、查询、列举、取消。这是 P1 选定的互通验收者——用 `@a2a-js/sdk` 自带 client 打自己只能算冒烟,不作为兼容证据(架构 §6)。
2. 记录实际用到的方法名、返回分支与不支持项。
3. 关闭 `experimental.agentCollaboration` 时,这些路由与公开卡片全部消失(404),且普通 daemon API 不受影响。
4. 幂等与作用域在传输层之后依然成立:同一 `messageId` 重发不产生第二个任务;第二个授权客户端读不到第一个的任务。上游已有断言,传输层不得绕过。
---
## 任务 2:Host 出站取件通道
### 已有(不要重写)
`packages/core/src/agents/workspace-agents/host-lease.ts`,18 条断言覆盖:
```ts
acquireRunLease(projectRoot, { threadId, runId, hostId, ttlMs? }, now?): Promise<LeaseResult<RunLease>>
renewRunLease(projectRoot, { threadId, runId, leaseId, ttlMs? }, now?): Promise<LeaseResult<RunLease>>
checkRunLease(projectRoot, { threadId, runId, leaseId }, now?): Promise<LeaseResult<RunLease>>
releaseRunLease(projectRoot,{ threadId, runId, leaseId }): Promise<LeaseResult<true>>
type LeaseRefusal = 'no_such_run' | 'not_leasable' | 'held_by_other_host'
| 'stale_lease' | 'attempt_moved_on'
```
`DEFAULT_RUN_LEASE_MS = 60_000`。已有的 Host 注册与心跳在 `packages/cli/src/serve/routes/agent-hosts.ts`(`POST /agent-hosts/enroll`、`POST /agent-hosts/:workspaceId/:hostId/heartbeat`),凭证校验用其中的 `hostSecret(req)` + `heartbeatAgentHost` 模式。
### 要做的
在 `routes/agent-hosts.ts` 里加两个端点:
1. **取件长轮询** — Host 主动来问有没有活。命中就 `acquireRunLease`,把 run 与提示词一起交出去。
2. **结果回传** — Host 交回执行结果。
同样包在 `agentCollaborationEnabled` 里。
### 必须守住的点
- **写入前一律先 `checkRunLease`,不要自己再造一套核对。** 这是本任务最容易做错的地方:租约的意义就是「消失过的 worker 不能覆盖接手者」,绕过它写一次就前功尽弃。
- **`stale_lease` 与 `attempt_moved_on` 要分开回报。** 前者是「你的租约被接管了」,后者是「这个 run 重启过,你手里的是上一轮的」——同一个 Host 也可能撞上后者。给同一个错误码,排查时分不出是哪种。
- **持久化后才确认,结果持久化后才重试发送。** 先确认再落盘,断网就会丢掉一次真实执行。
- **执行机器不得新增入站监听。** 全部出站,这是本步骤存在的理由。
- **Host 凭证不能领取他人的任务。** 照 heartbeat 的模式先核对 workspace 与 host 凭证,再谈租约。
- **断网显示失联或状态未知,不得未经核对就在另一台机器重跑有副作用的工作。** 租约到期让 run 可被再领取是设计,但「再领取」与「重跑副作用」是两回事——回传路径必须能识别重复结果。
### 过关条件
1. 执行机器无新增入站监听,仍能领取指定任务。
2. 用两个 Host 演示接管:A 领走后失联,租约到期 B 接手,**A 回来的写入被拒**,且拒绝理由是 `stale_lease`。
3. 同一 Host 在 run 重启后拿旧 `leaseId` 回传,被拒且理由是 `attempt_moved_on`。
4. Host 凭证 X 领不到属于 Host Y 的任务。
5. 断线重连后能找回原任务,不产生第二次副作用执行。
### 本轮观测
一次独立行为脚本实际观察到:Host X 无法领取未授权 Agent;本地 dispatcher 不接触 managed-host Agent;Host A 重连取得同一 `leaseId`;租约过期后 Host B 取得新 `leaseId`,Host A 的晚结果以 `stale_lease` 拒绝;旧 attempt 以 `attempt_moved_on` 拒绝;Host B 的结果先持久化并把线程推进 `in_review`,同一结果重发返回 `alreadyApplied=true`。
随后用第二个真实 daemon 进程完成执行端闭环:Host `host_d43cad67-c491-4915-9186-481732a0458e` 领取 run `rn_8158e46e-6166-4c68-b8e7-7047674dc3f8`,在隐藏的 task-scoped ACP session 中执行约 24.2 秒,正确读出根 `package.json` 的包名 `@qwen-code/qwen-code` 和 `engines.node >=22.0.0`,结果回传后线程 `th_65775393-3b1b-4deb-ac05-a504a8fcb537` 进入 `in_review`。取件、续租和回传都由 Host 出站请求发起;本轮第二个 Host 与协调端仍在同一台物理机上,尚未证明跨机器网络部署。
Demo 路径有意保持最小:Host 用 `--agent-host-provider qwen|codex` 选择本机执行器。Qwen 路径使用 task-scoped ACP session;Codex 路径启动真实 `codex exec --json --ephemeral --sandbox read-only` 子进程。两者的最终文本都由 Host 映射为 `review`,取件协议携带的 Agent instructions 已进入 prompt,但完整 agent type 基础 persona、逐 Agent model 覆盖和精确 tool ceiling 尚未装入远端 runtime,远端 Agent 也不能直接调用 `thread_*` 继续拆分。Codex 当前还是一次一进程,没有恢复同一 Codex thread。这些不阻塞「受管 Host 领取并完成任务」演示,但不能把当前实现描述为完整的远程多 Agent 对等协作。
Codex 实测(`codex-cli 0.144.6`):Host `host_d43cad67-c491-4915-9186-481732a0458e` 领取 run `rn_31371a42-59b9-4a94-971a-311211d84fb4` 后,进程表可见 Host 直接启动 `codex exec`;Codex 回报 `CODEX_PIPELINE_OK`、包名 `@qwen-code/qwen-code`、Node 范围 `>=22.0.0` 和 commit `890f3738dfb2f80d8812c1afa9e4b3696e6b75a5`。结果进入线程 `th_294bf774-1a76-4fda-83b7-3cd3df931512`,run 完成且线程进入 `in_review`,耗时约 62.4 秒。Claude Code 本机未安装,因此没有验收。
---
## 两项共同的规矩
- **只交付到 #11206,直接追加,不新拆 PR。**
- **不 rebase、不 force push**(仓库 bot 会标记)。推之前先 `git fetch`,基于当时最新 head 追加——从落后的 worktree 提交会静默回滚别人的改动。
- 不把 build、lint、typecheck、目录扫描或 CI 等待当作主线。
- 每段结束回报:branch/commit、改动、**实际观测值**、未通过项、下一步。没有过关证据就写未完成,不用百分比代替结果。
- 若发现上游语义有错,**先报回来再改**——那些模块有断言和变异验证支撑,改动前应该知道自己在推翻什么。

View file

@ -1,143 +0,0 @@
# Agent 服务双向接入:接续实施计划
状态:实施中;历史进度见 §3b,2026-09-13 评论修复与未完成项见 §3c。
权威方向见[接续架构](../design/2026-09-09-agent-service-collaboration.md)。读取基线为 #11206 `6a69c0b5bbf1232644a297b567bb36350eb58ff4`。本文件不宣称旧 ten-step 全部重验,也不要求按旧步骤继续堆功能。
## 1. 本轮交付与工作方式
- 本次在 #11206 分支追加接续架构、计划与旧文档入口指引。没有改其他 worktree、生产代码或用户提供的私有调研原件,不新开 PR 或 issue。
- 实施仍只交付到 #11206,直接追加,不新拆 PR;不 rebase、force push。每次接力前读取当时最新 head,与并行工作对齐。
- 不把 build、lint、typecheck、目录扫描测试或 CI 等待作为主线。本轮不跑这些。后续每段用最小实际行为观测验收;如需测试,只跑双方约定的命名文件,不以增加测试数量计进度。
- 每段结束即回报并交棒,不无限循环:branch/commit、改动、观测值、未通过项、设计被推翻的句子、下一步。未运行写未运行,不用百分比代替结果。
- 旧设计、验收、UI 与差距计划顶部已增加 successor 指引。架构 §8 列出的旧决定按历史背景阅读;未被替代的存储、安全约束继续有效。保留真实旧证据,不覆盖另一会话的未提交内容。
- `scripts/audit/` 决定:保留孤儿/字段/文档漂移扫描、两份窄范围 tsconfig 和规则 harness(`run-workspace-agents.mjs`)——它们在最近一次并行推进落地后几分钟内抓到两个编译错误和一处过期断言,成本低、有实效;退役 fuzz、跨进程并发、崩溃恢复三个脚本——它们回答的是一次性问题(锁是否跨进程成立、崩溃后是否自恢复),答案已记录在旧差距计划里,不需要常驻。保留的三项是绕过开关的内部接缝检查,不计入进度、不作主线;开关落地后 harness 的假 config 须显式开启协作开关,否则不覆盖任何协作路径。
## 2. 先保留什么、先停止什么
保留身份、按任务会话、任务消息与分工、排队/并发、幂等与 outbox、现有会话列表、ACP 本地适配、Host 配对基础。没有运行证据的可靠性路径不因此变绿。
停止扩大第二套对话列表、项目/标签/inbox 等 tracker 功能、全局运行时抽象及强制进程隔离。不能通过删除安全限制来演示“远程能运行”,也不使用 Agent 自称独立的文字作为执行隔离证明。
## 3. 有界顺序与过关条件
以下是实施顺序与门槛;实际状态以 §3b 为准。
### P0 — 实验开关与原有行为边界
沿用 Team opt-in,新增独立协作 opt-in(暂称 `experimental.agentCollaboration`)。新开关默认 false,启动时解析。外部开放仍要额外授权,不因功能开启自动联网。
前置:先定“关闭对照”怎么做,再写开关。要证明的是“两开关都关时,模型收到的请求里没有任何协作内容”。读代码不算证明——架构 §6 已说明,而且此前多轮核对都证明读代码会漏。要证明它,得能看到模型**实际收到**的最终 system instruction 和工具列表,然后关一次、开一次,比对两份。现在没有任何地方能拿到这两样东西;请求组装完直接发给模型,中间没有落点。可选做法:(a) 在 content generator 加一个仅测试环境可用的捕获钩子,把每次请求的最终 instruction 与 tool declarations 写到文件;(b) 加一个 dry-run 参数,只打印请求不发送;(c) 若已有调试日志能输出完整请求,直接用它。决定采用 (a):最小、不进生产路径、能被自动比对。捕获钩子只在测试环境注册,生产构建不携带;输出包含最终 systemInstruction、tool declarations 与触发该请求的 session sourceType,便于按会话类型分组比对。
优先入口:`Config.createToolRegistry`、Agent 工具声明/提示词构建、daemon 路由注册与恢复扫描、Host 客户端启动、UI capability 消费、会话创建/恢复时的真实所有权校验。不要仅隐藏按钮。完整落点见架构 §7,共约十二处,以该表为检查表;开关做成单一的 `Config` 判定,各处照现有 `isAgentTeamEnabled()` 在 `createToolRegistry` 的落法。
观测与门槛:
- 两开关均关:普通会话和普通 subagent 的实际模型请求中,新增协作 prompt/工具声明为 0;保留基线 subagent 和既有 send_message 行为。
- 用同一模型/配置对比最终 system instruction 与工具 schema,列出差异,不比较随机模型回答是否逐字相等。
- 在已有旧 roster、排队任务和 Host 凭证的环境中,关闭启动仍没有协作扫描、迁移、Host 心跳、claim、通知或自动模型运行。记录文件访问、协作定时器与网络调用观测,不只说空库没动作。
- 关闭时直接请求协作/Host 路由以及伪造 `sourceType: agent` 均不能激活功能;普通 daemon API 仍可用。开启时伪造同样不能拿到 persona 与工具面:这依赖架构 §6 的“服务器绑定”反查,该检查目前不存在,是 P0 必须新增的一项,不能只靠开关。
- 四种开关组合逐项核对;开启 Team 不启用 mesh,反之亦然;多个 workspace 不向 primary 回退。架构 §6 所说“各 workspace 和会话只可缩小范围”不在 P0:P0 只做 daemon 级的全局开关,按 workspace 缩小另作后续,不在 P0 里混做两层。
- 停用/重启保留现有本地任务记录但不自动恢复。停止任务结果与关闭功能分别说明;远端任务的同类观测在 P2 落地后补做,不让尚未存在的远端路径阻塞 P0。
- 关闭期间搁浅的 `running` run 在重开时进入搁浅终态等人处置,不被重排队、不被重派;开关关闭后恢复旧 `sourceType: agent` 会话被拒绝而非降级为普通会话(架构 §6 决定)。两者各有观测,不以“没报错”代替。
源码已显示上述入口有缺口,不能先宣称零影响。P0 不扩展到远程运行;通过后即可交棒。
### P1 — 冻结最小外部契约,不造第二套通用协议
选择 A2A 的具体版本、一个 transport binding 与匹配 SDK/schema;只采用真实消费者需要的可选能力。列出 required 操作、发现/认证、Message 与 Task 返回分支、task/context/原生 session 映射及幂等重试语义,以及远端用量是否随 Task/Message 回报、是否必需,和外部任务到本地 run 帧的映射通道(不经 `_meta`),以及按认证调用方与目标作用域的幂等键——现有存储只有 outbox 按事件 id 去重,没有任何调用方侧的键;这是 §2 所说“第一条外部路径落实时再增加”的字段之一,且必须在接单持久化之前写入,否则 §3.1 的“同键不同内容明确拒绝”无从判断。不能把普通 REST 路由改名就标 A2A-compatible。
定义 Agent 调用授权与 Host 执行授权的不同范围;模型不持凭证。明确第一项外部工作的数据输入、环境与审批人。
门槛:用固定规范逐项记录映射与不支持项,并选定一个独立客户端作为互通验收者。不在此阶段搭完整认证平台、创建新队列存储或批量迁移全部 schema。
### P2 — 可达网络上的 Qwen 双向服务闭环
服务端 B 预先定义一个 Agent,调用方 A 接入该已有身份,不复制 persona。复用本地 ACP 执行和任务存储。接入权限逐操作检查,不交出 daemon 管理 token。
最小实机示例:B 对预先授权的样例仓库做只读分析;A 从普通对话发起并接收结果。代码写入与任意 MCP 放行不作为此步骤默认内容。
门槛与必须回报的值:
- 记录 A/B 地址类型、Agent ID、grant 的权限范围与远端任务 ID;日志不含实际密钥。
- 接单、排队、执行、结果及本地验收的状态各有观测;不是单纯 curl 成功。
- 同一任务追加要求后会话继续,不新建身份;不支持中途消费就明确下一轮处理。
- 重发同一请求不会多出远端任务;断线后查询原任务能找回结果。不同内容重用同键有明确错误。
- 第二个授权客户端也能调用 B;不能读取第一个客户端的私有任务。B 的并发和排队由所有调用方共享。
- 再由 A 显式开放一个 Agent 给 B,反向执行一次;单向授权不自动赋予反向权限。关闭并重启调用方后,重开功能能核对既有远端任务而不重复派发。
- 错凭证、越权 Agent、撤销后的调用被拒绝;取消回执与实际停止分别记录。审批没有被自动绕过。
- 模型收到的上下文不含另一任务材料或调用凭证。
任一权限边界失败先停该开放入口,不用 UI 修饰掩盖。通过后停止扩展 Qwen 专有功能,进入异构验证。
### P3 — 远程 Codex,尽早揭露 Qwen 专有假设
在执行机器上接 Codex App Server,记录实际 Codex 版本;不要求安装或调用 Qwen 模型。若 Codex 已由外部服务开放,直接接该服务,不重复托管。
门槛:同一个工作界面能把任务交给远程 Codex,记录 task/thread/turn 对应、收到结果,并在同一任务上下文追加要求;可用的取消与审批正确回传。工作由真实 Codex 执行,不由 Qwen 代答。功能缺失明确报告,不从 CLI 退出码推断整项协作已验收。
Qwen ↔ Codex 的第一轮先证明“能接活”。主动调用其他 Agent 的协作工具另作本步骤后半段,必须有单独的授权对象、可见调用链和结果映射;不能直接把普通文本 `@` 当作远端操作。
### P4 — 受管内网 Host 出站取件
参考 CoCo 调研的出站长轮询,复用已有 Host enrollment/heartbeat;协调端必须实际可达,不自动搭建云中继。它是另一种执行接入,不替代 P2/P3 的外部服务方式。“复用已有”只覆盖注册:当前 `routes/agent-hosts.ts` 仅有 `POST /agent-hosts/enroll`,没有任何领取、租约、回传端点,取件长轮询、租约/attempt 握手与结果回传三者全部是新建,不应按“大部分已存在”估算。
门槛:执行机器无新增入站监听,仍能领取指定任务;Host 凭证不能领取他人的任务;重连与租约/attempt 核对阻止旧 worker 改写新执行。任务持久化后才确认,结果持久化后重试发送。断网显示失联或状态未知,不未经核对在另一台机器重跑有副作用工作。
若第一条实际部署已经处于 NAT 后且无法直连,把本步骤的最小出站通道提前到 P2,仅解决那一条部署;不要同时实现 WebSocket、长轮询与公网穿透。
### P5 — 收束交互,而不是新增任务平台
P2/P3 已有最小可见入口;此步只收束体验:原有新建对话、默认 workspace、可选负责人、授权 Agent 的接收人补全、同页分工与验收。管理页区分创建本地 Agent 与接入已有 Agent。普通问答不强制变任务。
门槛:用户不先建 Team、不另开任务 tab,也能发起一次本地加远程协作;能看谁在排队、谁需要回答、最终结果归谁。能力关闭后新增入口和后台请求消失,原有定义管理、聊天和 subagent 仍正常。
## 3b. 本轮实际进度(2026-09-09 追加)
以下按段记录,未通过项写未通过,不用百分比。四个审计脚本当时全绿(`scripts/audit/` 后已整体移除,不再可复跑):
| 脚本 | 断言数 | 覆盖 |
| ------------------------------------ | ------ | ------------------------------------------------------------------------------------------ |
| `run-workspace-agents.mjs` | 373 | 存储与派发规则、线程工具、开关落点、绑定、搁浅、契约、接单、取消、Host 取件与结果 |
| `check-a2a-transport.mjs` | 34 | 真起 express 打真 HTTP:公开卡片、版本协商、授权拒绝的不可区分性、幂等、按调用方隔离、取消 |
| `check-agent-collaboration-gate.mjs` | 21 | 真实 `Config` 与工具注册表的六组合观测、两开关独立性、capability tag |
| `check-request-capture.mjs` | 15 | 关闭对照的捕获钩子 |
`check-a2a-transport.mjs` 在 `@a2a-js/sdk` 未安装时以 exit 0 跳过并说明,不伪装成通过。
- **P0 已完成。** 架构 §7 的十四行全部落点;新增 `experimental.agentCollaboration`(daemon 启动解析一次)、`agent_collaboration_v1` capability、服务器绑定反查、搁浅终态。观测发现两处读代码没发现的问题:门里的 `forSubAgent` 多余且有害;服务器绑定按原有顺序会把派发器自己锁死(`bindRunSession` 在 `port.start()` 之后)。**未做:** 带旧 roster/排队任务/Host 凭证、关闭启动时的文件访问、定时器与网络调用记录——需要真跑 daemon。web-shell 消费端无测试覆盖。
- **P1 已完成。** 契约冻结在 `a2a-contract.ts`:协议 `1.0`、`JSONRPC`、`@a2a-js/sdk@1.1.0`;映射与不支持项见 [冻结契约](../design/2026-09-09-a2a-frozen-contract.md)。互通验收者定为 `a2a-sdk`(Python)。
- **P2 服务端与 A2A JSON-RPC 传输已完成,真实 A/B 执行闭环未做。** `@a2a-js/sdk@1.1.0` Express 服务端已挂在协作开关后;公开 card 不枚举 Agent,认证 card 只列 grant 允许的 Agent。Python `a2a-sdk==1.1.2` 实测接单、查询、列举、取消、同 `messageId` 幂等、同键异内容冲突回执及第二调用方隔离均通过;关闭协作开关时 card 为 404、普通 `/health` 为 200。冻结契约 §8 的“没有任何传输层”是 P1 当时的状态快照,已被本项取代。**未做:** 真实两台可达服务之间的任务执行、结果回传与反向调用。
- **P3 的首个真实 Codex 执行已完成,完整门槛未完成。** Host 以 `codex-cli 0.144.6` 启动真实只读 `codex exec`,run `rn_31371a42-59b9-4a94-971a-311211d84fb4` 回报精确仓库事实并进入共享线程 `in_review`,进程表证明工作不是 Qwen 代答。当前用的是 Codex CLI 一次性进程,不是计划指定的 App Server;同任务续跑、取消、审批回传以及 §5 的 `agentMessage → unclosed` 规则都尚未接入,所以不能把 P3 标成完成。
- **P4 的 Demo 闭环已完成。** Agent 定义归 workspace,`execution` 明确授权可领取它的 managed Host;协调端已有长轮询、租约/attempt 校验和幂等结果回传,执行端已有领取、续租、本机 Qwen ACP 或 Codex CLI 执行与回传循环。Qwen run `rn_8158e46e-6166-4c68-b8e7-7047674dc3f8` 约 24.2 秒完成;Codex run `rn_31371a42-59b9-4a94-971a-311211d84fb4` 约 62.4 秒完成。两者都把真实结果写回共享线程并推进 `in_review`。双 Host 状态机演示仍证明旧 worker 分别以 `stale_lease` 和 `attempt_moved_on` 被拒。**未做:** 跨物理机器部署;完整 agent type persona/model/tool ceiling;远端 Agent 直接调用 `thread_*`;Claude Code(本机未安装)。
- **P5 的工作台页面和视图逻辑已可用于 Demo。** Agents、Tasks 与 Runtime 页面能看到 managed-host Agent、远端 Host 在线状态、任务归属和最终 review。**未做:** 把本地与远程 Agent 的创建、分工和验收进一步收束进原有对话入口。
仍需产品决定:生产 A/B 环境与可达方式、远端 Agent 的最终权限上限、审批接收人;以及上面 P3 那条后果是否接受。它们不阻塞当前只读 Demo。
下一棒入口见 [A2A 传输层与 Host 出站取件](./2026-09-09-a2a-transport-and-host-pickup-handoff.md):A2A 与受管 Host 的只读执行闭环已完成;Demo 后再补完整 persona/tool ceiling 和跨机器部署。
## 3c. PR 评论复核(2026-09-13)
基线:远程 `214b895b1b97`,本地已合入 main `6a0806faf313`。本轮不宣称旧阶段重新验收,不删除会话记录。
- **A2A 任务存在性泄漏已修。** JSON-RPC 的认证入口现在验证真实 grant,而非只检查请求头形状;核心 `getTask` / `cancelTask` 将错误或撤销凭据与不存在的任务统一为 `not_found`。按 Agent 的授权检查继续保留。前后对照中,错误 secret / 撤销 secret × get / cancel 四组从“已有任务 refused、缺失任务 not_found”变为不可区分。
- **Host 心跳不再接单。** 复用已有 heartbeat 与 `renewRunLease`,只延长当前 `(hostId, threadId, runId, leaseId, attempt)`;保持 leaseId,拒绝过期、取消、错误 Host 与旧 attempt。启动执行前先确认一次,此后每 20 秒续租。任何续租失败(含 10 秒请求超时)都发出取消信号,不把结果当正常完成;因此短暂网络故障也可能中断本次执行。旧协调端若不返回租约确认,新 Host 拒绝执行,需先升级协调端。`pickup` 只负责领取新工作。
- **观测边界。** 临时真实 store + client 定时器证明:取消 / 过期后下一任务保持 queued,当前 prompt 收到 abort,执行分别报 `not_leasable` / `stale_lease`;正常续租保持 leaseId 并延长 expiresAt。真实 Express heartbeat:正常 200、非法 tuple 400、错误凭据 401、另一 Host 伪造身份 409 stale_lease、错误 attempt 409 attempt_moved_on。未启动真实模型,未验证模型进程最终退出时间;本机缺少 A2A SDK,认证函数单独验证,不算完整 SDK HTTP 或 Python 互通重验。没有跑 build、lint、typecheck 或全套测试。
- **提示词格式收窄。** 标题、正文与验收条件改为标记 untrusted 的单行 JSON 字符串,避免换行伪装成帧字段。ambient 绑定仍是操作权限边界;这不等于解决模型提示词注入。
- **历史容量 Critical 仍未解决。** 旧设计 §9.2 的 200 / 500 是软裁剪阈值,不是容量保证;计费 run、其引用消息及已结束 outbox 可持续增长。不能直接删除它们,否则预算计费与重放去重会失效,也会再次丢失对话。需先明确归档与压缩后账本 / 去重键的保存方式,本轮不做历史清理。
当前仍不能称为完整的多 Agent 协作:受管 Host 只接活并回传 review,尚不能使用 `thread_*` 主动交接;Codex 仍是 `exec --ephemeral`,没有原生会话续跑;同一 Host 连接串行执行。§3b 的跨机器与双向调用缺口不能由这次故障复现覆盖。本地尚未提交的聊天入口 / 进度显示改动也不计入远程交付。
## 3d. 实时正文接线(2026-09-13)
替代 §3c 的 Codex `exec --ephemeral` 描述:Host 现在复用仓库已有 App Server 传输,消费正文 delta;仍使用临时 thread,未实现跨接单原生续跑。Qwen 本地与 Host 接入 ACP 正文流,输出以 run 快照进入原共享 Chat;设计与限制见[实时输出说明](../design/2026-09-11-agent-project-host-entry.zh-CN.md)。真实 Codex transport 已观测到首正文 25.48 秒、完成 28.84 秒、245 次正文回调;这只是 transport 观测,不替代页面和跨机器验收。
## 4. 接力清单
下一棒先读架构 §6、§7,然后执行 P0,不直接接着旧 ten-step 或 H2 开始写远程派发。
需要在实施时带回人的决定:实际 A/B 环境与可达方式、首个开放 Agent 的执行权限、审批接收人;协议版本须有具体兼容说明。父子线程回复、blocker 确认与通知目标等旧开放问题仍不自行拍板。
本轮核对后新增的四项——P0 关闭对照的捕获方式(采用 (a))、搁浅 run 的重开处置、开关关闭后旧 agent 会话的恢复行为、`scripts/audit/` 的去留——均已决定并写入。仍待确认的只有一项,且不阻塞 P0:Codex turn 结束时“什么信号算明确任务结果”的映射(架构 §5 的建议),须在 P3 开始前定。另有一项标为尚未做、不是待决定:架构 §8 中 §9.1/§9.6 关于按任务隔离后删除线程与 transcript 归属的重新分析。
每步回报观测对象、输入、状态/ID/数量、失败或限制与下一棒入口即可。没有过关证据就保持未完成,不追加无关 issue/PR、不跑长时间 CI 等待、不为完成数字扩展范围。

View file

@ -41,7 +41,6 @@
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.24.4"
},
"dependencies": {
"@a2a-js/sdk": "^1.1.0",
"@agentclientprotocol/sdk": "^0.14.1",
"@google/genai": "2.6.0",
"@iarna/toml": "^2.2.5",

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { parseJoinLink } from '../serve/agent-host-join.js';
import type { Argv, CommandModule } from 'yargs';
import type { ServeChannelSelection } from '../serve/types.js';
import type { RunHandle } from '../serve/run-qwen-serve.js';
@ -217,12 +216,6 @@ interface ServeArgs {
'token-qr'?: boolean;
'local-control': boolean;
'local-control-address'?: string;
'agent-host-server'?: string;
'agent-host-workspace-id'?: string;
join?: string;
'agent-host-name'?: string;
'agent-host-provider': 'qwen' | 'codex';
'agent-host-allow-http'?: boolean;
// Read from the kebab-case key only — the camelCase mirror that yargs
// synthesizes is convenient for handlers but type-confusing here. The
// handler reads `argv['http-bridge']` directly.
@ -416,36 +409,6 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
description:
'Which local IPv4 address to share when the host is on more than one network. Only needed if --local-control reports an ambiguous choice.',
})
.option('join', {
type: 'string',
description:
'Join another Qwen Code as a runtime, with the one-line link it shows under Agent → Runtime → Add runtime.',
})
.option('agent-host-server', {
type: 'string',
description:
'Register this daemon as an Agent Host with a primary Qwen daemon.',
})
.option('agent-host-workspace-id', {
type: 'string',
description:
'Control-plane workspace id printed by the primary daemon.',
})
.option('agent-host-name', {
type: 'string',
description: 'Display name advertised for this Agent Host.',
})
.option('agent-host-provider', {
choices: ['qwen', 'codex'] as const,
default: 'qwen' as const,
description: 'Agent runtime launched for work claimed by this Host.',
})
.option('agent-host-allow-http', {
type: 'boolean',
default: false,
description:
'Allow unencrypted Agent Host HTTP connections outside loopback (trusted demo networks only).',
})
.check((argv) => {
// A wildcard or LAN primary bind already owns the port Local Control
// needs on its selected address. Token and Origin settings remain
@ -470,25 +433,6 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
if (argv['local-control-address'] === '') {
throw new Error('--local-control-address must not be empty.');
}
if (
Boolean(argv['agent-host-server']) !==
Boolean(argv['agent-host-workspace-id'])
) {
throw new Error(
'--agent-host-server and --agent-host-workspace-id must be used together.',
);
}
if (argv['agent-host-name'] === '') {
throw new Error('--agent-host-name must not be empty.');
}
if (argv['join'] !== undefined) {
if (argv['agent-host-server'] || argv['agent-host-workspace-id']) {
throw new Error(
'--join already names the coordinator and workspace; drop --agent-host-server and --agent-host-workspace-id.',
);
}
parseJoinLink(argv['join']);
}
return true;
})
.option('event-ring-size', {
@ -741,9 +685,6 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
'Requires --rate-limit.',
}) as unknown as Argv<ServeArgs>,
handler: async (argv) => {
const agentHostEnrollmentToken =
process.env['QWEN_AGENT_HOST_ENROLLMENT_TOKEN']?.trim();
delete process.env['QWEN_AGENT_HOST_ENROLLMENT_TOKEN'];
if (!argv['http-bridge']) {
writeStderrLine(
'qwen serve: --no-http-bridge (native mode) is not yet implemented; ' +
@ -926,7 +867,6 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
const { runQwenServe } = await import('../serve/run-qwen-serve.js');
try {
const serveOptions = {
agentHostWorker: Boolean(argv['agent-host-server']),
port: argv.port,
hostname: argv.hostname,
token: argv.token,
@ -1037,38 +977,6 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
applyOpenWithAuth(serveOptions);
}
const handle = await runQwenServe(serveOptions);
const joined = argv['join'] ? parseJoinLink(argv['join']) : undefined;
const hostTarget = joined
? joined
: argv['agent-host-server'] && argv['agent-host-workspace-id']
? {
serverUrl: argv['agent-host-server'],
workspaceId: argv['agent-host-workspace-id'],
token: agentHostEnrollmentToken,
}
: undefined;
if (hostTarget) {
try {
const { startAgentHostConnection } = await import(
'../serve/agent-host-client.js'
);
await startAgentHostConnection({
bridge: handle.bridge,
serverUrl: hostTarget.serverUrl,
workspaceId: hostTarget.workspaceId,
workspaceCwd: primaryWorkspaceArg(argv.workspace) ?? process.cwd(),
provider: argv['agent-host-provider'],
allowHttp: argv['agent-host-allow-http'] === true,
...(hostTarget.token ? { enrollmentToken: hostTarget.token } : {}),
...(argv['agent-host-name']
? { name: argv['agent-host-name'] }
: {}),
});
} catch (error) {
await handle.close().catch(() => undefined);
throw error;
}
}
// Open the Web Shell in a browser once the listener is up (best-effort;
// never throws — see maybeOpenWebShellBrowser).
if (argv['local-control']) {

View file

@ -56,113 +56,22 @@ function id(value: unknown): string {
return value;
}
type CodexAppServerParams = {
command: string;
args?: string[];
cwd: string;
maxTimeMinutes?: number;
onMessage?: (itemId: string, text: string) => void;
onThought?: (delta: string) => void;
onActivity?: (stage: string, detail: string) => void;
onCleanupWarning?: (detail: string) => void;
keepAlive?: boolean;
session?: {
threadId?: string;
save: (threadId: string) => Promise<void>;
};
};
type CodexNextTurn = {
params: CodexAppServerParams;
prompt: string;
signal: AbortSignal;
};
type CodexConnection = {
iterator: AsyncGenerator<string, void, CodexNextTurn | undefined>;
busy: boolean;
timer?: ReturnType<typeof setTimeout>;
};
const codexConnections = new Map<string, CodexConnection>();
const codexExitHandlers = new Set<() => void>();
const stopCodexOnExit = () => {
for (const stop of codexExitHandlers) stop();
};
export async function runCodexAppServer(
params: CodexAppServerParams,
async function runCodex(
params: ExternalAgentExecutorParams,
prompt: string,
sandbox: string,
signal: AbortSignal,
): Promise<string> {
if (signal.aborted) throw new CodexInterruption(AgentTerminateMode.CANCELLED);
const cacheKey = () =>
JSON.stringify([
params.command,
params.args,
params.cwd,
sandbox,
params.session?.threadId,
]);
const cached = params.session?.threadId
? codexConnections.get(cacheKey())
: undefined;
const connection: CodexConnection = cached ?? {
iterator: codexAppServer(params, prompt, sandbox, signal),
busy: false,
};
if (connection.busy)
throw new Error('Codex session already has an active turn.');
connection.busy = true;
clearTimeout(connection.timer);
const close = async () => {
if (codexConnections.get(cacheKey()) === connection)
codexConnections.delete(cacheKey());
await connection.iterator.return(undefined);
};
try {
const result = await connection.iterator.next(
cached ? { params, prompt, signal } : undefined,
);
if (result.done) throw new Error('Codex session closed before replying.');
if (params.keepAlive && params.session?.threadId) {
codexConnections.set(cacheKey(), connection);
// ponytail: keep idle conversations warm for five minutes, then resume from disk.
connection.timer = setTimeout(
() =>
void close().catch((error: unknown) => {
debugLogger.warn(`Codex idle cleanup failed: ${String(error)}`);
}),
300_000,
);
connection.timer.unref();
} else {
await close();
}
return result.value;
} catch (error) {
await close();
throw error;
} finally {
connection.busy = false;
}
}
async function* codexAppServer(
params: CodexAppServerParams,
prompt: string,
sandbox: string,
signal: AbortSignal,
): AsyncGenerator<string, void, CodexNextTurn | undefined> {
if (signal.aborted) throw new CodexInterruption(AgentTerminateMode.CANCELLED);
const env = sanitizeChildEnv(process.env);
delete env['CODEX_THREAD_ID'];
delete env['CLAUDECODE'];
delete env['NODE_OPTIONS'];
const child = spawn(
params.command,
params.args ?? ['app-server', '--stdio'],
params.spec.command,
params.spec.args ?? ['app-server', '--stdio'],
{
cwd: params.cwd,
cwd: params.runtimeContext.getTargetDir(),
env,
stdio: 'pipe',
detached: process.platform !== 'win32',
@ -172,26 +81,6 @@ async function* codexAppServer(
const tracked = new ProcessRegistry().reserve().attach(child, {
ownsProcessTree: true,
});
params.onActivity?.(
params.session?.threadId ? 'resuming' : 'starting',
`Codex PID ${child.pid} · ${params.session?.threadId ? '恢复原会话' : '启动会话'}`,
);
const onHostExit = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
try {
process.kill(
process.platform === 'win32' ? child.pid : -child.pid,
'SIGTERM',
);
} catch {
/* Already exited. */
}
}
};
if (params.keepAlive) {
if (codexExitHandlers.size === 0) process.once('exit', stopCodexOnExit);
codexExitHandlers.add(onHostExit);
}
child.stderr.resume();
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
const pending = new Map<
@ -202,7 +91,6 @@ async function* codexAppServer(
let threadId: string | undefined;
let turnId: string | undefined;
let finalAnswer: string | undefined;
const messageText = new Map<string, string>();
let unphasedAnswer: string | undefined;
let terminal = false;
let exitDrainTimer: ReturnType<typeof setTimeout> | undefined;
@ -211,7 +99,7 @@ async function* codexAppServer(
fail = reject;
});
let complete!: (turn: JsonObject) => void;
let completed = new Promise<JsonObject>((resolve) => {
const completed = new Promise<JsonObject>((resolve) => {
complete = resolve;
});
const write = (frame: JsonObject) => {
@ -238,7 +126,7 @@ async function* codexAppServer(
fail(
new Error(
error.code === 'ENOENT'
? `Cannot start ${params.command}. Install Codex and make it available on PATH.`
? `Cannot start ${params.spec.command}. Install Codex and make it available on PATH.`
: `Cannot start Codex: ${error.code ?? 'process error'}`,
),
);
@ -336,39 +224,12 @@ async function* codexAppServer(
terminal = true;
complete(turn);
}
} else if (method === 'item/agentMessage/delta') {
associateTurn(parameters['turnId']);
const itemId = id(parameters['itemId']);
if (typeof parameters['delta'] !== 'string')
throw new Error('Codex returned an invalid text delta.');
const text = (messageText.get(itemId) ?? '') + parameters['delta'];
messageText.set(itemId, text);
params.onMessage?.(itemId, text);
} else if (method === 'item/reasoning/summaryTextDelta') {
associateTurn(parameters['turnId']);
if (typeof parameters['delta'] === 'string')
params.onThought?.(parameters['delta']);
} else if (method === 'item/started') {
associateTurn(parameters['turnId']);
const item = object(parameters['item']);
if (item['type'] === 'reasoning')
params.onActivity?.('thinking', 'Codex 正在思考');
else if (
[
'commandExecution',
'mcpToolCall',
'webSearch',
'fileChange',
].includes(String(item['type']))
)
params.onActivity?.('tool', 'Codex 正在执行工具');
} else if (method === 'item/completed') {
associateTurn(parameters['turnId']);
const item = object(parameters['item']);
if (item['type'] !== 'agentMessage') return;
if (typeof item['text'] !== 'string')
throw new Error('Codex returned an invalid message.');
if (params.onMessage) params.onMessage(id(item['id']), item['text']);
if (item['phase'] === 'final_answer') finalAnswer = item['text'];
else if (item['phase'] == null) unphasedAnswer = item['text'];
}
@ -388,8 +249,8 @@ async function* codexAppServer(
() => fail(new Error('Codex initialization timed out.')),
10_000,
);
const minutes = params.maxTimeMinutes;
let executionTimer =
const minutes = params.runConfig.max_time_minutes;
const executionTimer =
minutes === undefined
? undefined
: setTimeout(
@ -397,38 +258,24 @@ async function* codexAppServer(
minutes * 60_000,
);
const run = async () => {
if (!threadId) {
await request('initialize', {
clientInfo: { name: 'qwen-code', version: '1.0.0' },
capabilities: { experimentalApi: false },
});
write({ method: 'initialized' });
const resumeId = params.session?.threadId;
const started = await request(
resumeId ? 'thread/resume' : 'thread/start',
{
cwd: params.cwd,
...(resumeId
? { threadId: resumeId }
: { ephemeral: !params.session }),
approvalPolicy: 'never',
sandbox,
},
);
const thread = object(started['thread']);
threadId = id(thread['id']);
if (resumeId && threadId !== resumeId)
throw new Error('Codex resumed a different thread.');
if (params.session && thread['ephemeral'] === true)
throw new Error('Codex did not create a persistent thread.');
if (!params.session && thread['ephemeral'] !== true)
throw new Error('Codex did not create an ephemeral thread.');
await params.session?.save(threadId);
clearTimeout(initTimer);
}
await request('initialize', {
clientInfo: { name: 'qwen-code', version: '1.0.0' },
capabilities: { experimentalApi: false },
});
write({ method: 'initialized' });
const started = await request('thread/start', {
cwd: params.runtimeContext.getTargetDir(),
ephemeral: true,
approvalPolicy: 'never',
sandbox,
});
const thread = object(started['thread']);
threadId = id(thread['id']);
if (thread['ephemeral'] !== true)
throw new Error('Codex did not create an ephemeral thread.');
clearTimeout(initTimer);
const startedTurn = await request('turn/start', {
threadId,
...(params.onThought ? { summary: 'auto' } : {}),
input: [{ type: 'text', text: prompt, text_elements: [] }],
});
associateTurn(object(startedTurn['turn'])['id']);
@ -445,37 +292,8 @@ async function* codexAppServer(
let completedAnswer: string | undefined;
let interruption: CodexInterruption | undefined;
try {
for (;;) {
completedAnswer = await Promise.race([run(), failure]);
clearTimeout(executionTimer);
signal.removeEventListener('abort', abort);
const next = yield completedAnswer;
if (!next) return;
({ params, prompt, signal } = next);
if (signal.aborted)
throw new CodexInterruption(AgentTerminateMode.CANCELLED);
params.onActivity?.(
'resuming',
`Codex PID ${child.pid} · 复用进程,继续原会话`,
);
turnId = undefined;
finalAnswer = undefined;
unphasedAnswer = undefined;
completedAnswer = undefined;
messageText.clear();
terminal = false;
completed = new Promise<JsonObject>((resolve) => {
complete = resolve;
});
signal.addEventListener('abort', abort, { once: true });
executionTimer =
params.maxTimeMinutes === undefined
? undefined
: setTimeout(
() => fail(new CodexInterruption(AgentTerminateMode.TIMEOUT)),
params.maxTimeMinutes * 60_000,
);
}
completedAnswer = await Promise.race([run(), failure]);
return completedAnswer;
} catch (error) {
if (error instanceof CodexInterruption) interruption = error;
throw error;
@ -484,9 +302,6 @@ async function* codexAppServer(
clearTimeout(executionTimer);
clearTimeout(exitDrainTimer);
child.removeListener('exit', onExit);
codexExitHandlers.delete(onHostExit);
if (codexExitHandlers.size === 0)
process.removeListener('exit', stopCodexOnExit);
signal.removeEventListener('abort', abort);
lines.close();
for (const reply of pending.values())
@ -499,7 +314,13 @@ async function* codexAppServer(
if (isUnprovenExternalAgentTreeExit(error)) {
const diagnostic = `Codex process tree not proven gone after cleanup: ${detail}`;
debugLogger.warn(diagnostic);
params.onCleanupWarning?.(diagnostic);
if (params.eventEmitter?.rawListeners(AgentEventType.ERROR).length) {
params.eventEmitter.emit(AgentEventType.ERROR, {
subagentId: params.subagentId ?? params.name,
error: diagnostic,
timestamp: Date.now(),
});
}
} else if (!isExpectedExternalAgentCleanupExit(error)) {
if (interruption) {
interruption.message += `\n\nCodex cleanup failed: ${detail}`;
@ -609,22 +430,8 @@ class CodexSubagentExecutor implements SubagentExecutor {
this.params.runtimeContext,
);
const task = String(context.get('task_prompt') ?? 'Get Started!');
this.finalText = await runCodexAppServer(
{
command: this.params.spec.command,
args: this.params.spec.args,
cwd: this.params.runtimeContext.getTargetDir(),
maxTimeMinutes: this.params.runConfig.max_time_minutes,
onCleanupWarning: (error) => {
if (this.emitter.rawListeners(AgentEventType.ERROR).length) {
this.emitter.emit(AgentEventType.ERROR, {
subagentId,
error,
timestamp: Date.now(),
});
}
},
},
this.finalText = await runCodex(
{ ...this.params, eventEmitter: this.emitter },
[system, task].filter(Boolean).join('\n\n'),
this.sandbox,
this.controller.signal,

View file

@ -1,649 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { isCommandAvailable } from '@qwen-code/qwen-code-core/utils/shell-utils.js';
import { createHash, randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import type {
HostRunAssignment,
HostRunResult,
} from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core/config/approval-mode.js';
import { SessionService } from '@qwen-code/qwen-code-core/services/sessionService.js';
import { Storage } from '@qwen-code/qwen-code-core/config/storage.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import type { AcpSessionBridge } from './acp-session-bridge.js';
import { runCodexAppServer } from '../external-agents/codex-subagent-executor.js';
import { streamAgentTurn } from './workspace-agents/stream-agent-turn.js';
import { codexHostSession } from './workspace-agents/codex-host-session.js';
import { isLoopbackBind } from './loopback-binds.js';
import {
AGENT_HOST_SESSION_SOURCE_TYPE,
agentThreadSessionId,
} from '../runtime/agent-session-source.js';
const HEARTBEAT_MS = 5_000;
const LEASE_RENEW_MS = 20_000;
const RETRY_MS = 2_000;
const PROVIDER_LABELS = {
qwen: 'Qwen Code ACP',
codex: 'Codex CLI',
} as const;
type AgentHostProvider = keyof typeof PROVIDER_LABELS;
let detectedProviders: AgentHostProvider[] | undefined;
/**
* What this host can run: Qwen Code always (it is this process), Codex when
* its CLI is installed or was asked for. Advertised as a list so one machine
* shows up as one runtime offering several programs.
*/
function hostProviders(preferred: AgentHostProvider): string[] {
detectedProviders ??= [
'qwen',
...(preferred === 'codex' || isCommandAvailable('codex').available
? (['codex'] as const)
: []),
];
return detectedProviders.map((provider) => PROVIDER_LABELS[provider]);
}
interface AgentHostCredential {
schemaVersion: 1;
serverUrl: string;
workspaceId: string;
hostId: string;
secret: string;
}
export interface AgentHostConnectionOptions {
bridge: AcpSessionBridge;
serverUrl: string;
workspaceId: string;
workspaceCwd: string;
provider: keyof typeof PROVIDER_LABELS;
enrollmentToken?: string;
allowHttp?: boolean;
name?: string;
}
function normalizeServerUrl(value: string, allowHttp = false): string {
const url = new URL(value);
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username ||
url.password
) {
throw new Error('--agent-host-server must be an HTTP(S) URL.');
}
if (url.protocol === 'http:' && !isLoopbackBind(url.hostname) && !allowHttp) {
throw new Error(
'--agent-host-server requires HTTPS outside loopback. For a trusted demo network only, explicitly pass --agent-host-allow-http.',
);
}
return url.toString().replace(/\/$/, '');
}
function credentialPath(
serverUrl: string,
workspaceId: string,
workspaceCwd: string,
): string {
const key = createHash('sha256')
.update(`${serverUrl}\0${workspaceId}\0${workspaceCwd}`)
.digest('hex');
return path.join(Storage.getGlobalQwenDir(), 'agent-hosts', `${key}.json`);
}
async function readCredential(
filePath: string,
): Promise<AgentHostCredential | undefined> {
try {
const value = JSON.parse(
await fs.readFile(filePath, 'utf8'),
) as Partial<AgentHostCredential>;
if (
value.schemaVersion === 1 &&
typeof value.serverUrl === 'string' &&
typeof value.workspaceId === 'string' &&
typeof value.hostId === 'string' &&
typeof value.secret === 'string'
) {
return value as AgentHostCredential;
}
throw new Error(`Malformed Agent Host credential: ${filePath}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
async function writeCredential(
filePath: string,
credential: AgentHostCredential,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
const temporary = `${filePath}.${randomUUID()}.tmp`;
await fs.writeFile(temporary, `${JSON.stringify(credential, null, 2)}\n`, {
mode: 0o600,
flag: 'wx',
});
await fs.rename(temporary, filePath);
}
async function requestJson<T>(url: string, init: RequestInit): Promise<T> {
const response = await fetch(url, init);
const result = (await response.json().catch(() => ({}))) as {
error?: string;
} & T;
if (!response.ok) {
throw new Error(
result.error ?? `Agent Host request failed (${response.status}).`,
);
}
return result;
}
async function pickup(
serverUrl: string,
credential: AgentHostCredential,
waitMs = 25_000,
): Promise<HostRunAssignment | undefined> {
const response = await fetch(
`${serverUrl}/agent-hosts/${encodeURIComponent(credential.workspaceId)}/${encodeURIComponent(credential.hostId)}/pickup`,
{
method: 'POST',
headers: {
authorization: `AgentHost ${credential.secret}`,
'content-type': 'application/json',
},
body: JSON.stringify({ waitMs }),
},
);
if (response.status === 204) return undefined;
const result = (await response.json().catch(() => ({}))) as {
assignment?: HostRunAssignment;
error?: string;
};
if (!response.ok || !result.assignment) {
throw new Error(
result.error ?? `Agent Host pickup failed (${response.status}).`,
);
}
return result.assignment;
}
function modelPrompt(assignment: HostRunAssignment): string {
const instructions = assignment.agent.instructions?.trim();
return [
`You are ${assignment.agent.name}, an independent persistent workspace Agent running on a managed Host.`,
instructions ? `Your workspace instructions:\n${instructions}` : undefined,
'Work on the assigned task using read-only inspection tools. Do not call thread_* tools on this Host. End with a concise result for the parent Agent or person; the Host will post it back to the shared thread.',
assignment.prompt,
]
.filter(Boolean)
.join('\n\n');
}
/**
* The program an assignment asks for: the one its agent is bound to when this
* host offers it, else the host's default. Lets one machine run a Qwen Code
* agent and a Codex agent side by side.
*/
function providerFor(
assignment: HostRunAssignment,
fallback: AgentHostProvider,
): AgentHostProvider {
const execution = assignment.agent.execution;
const wanted =
execution?.mode === 'managed-host' ? execution.provider : undefined;
return wanted && detectedProviders?.includes(wanted) ? wanted : fallback;
}
async function executeAssignment(
options: AgentHostConnectionOptions,
credential: AgentHostCredential,
assignment: HostRunAssignment,
): Promise<HostRunResult> {
const promptId = `agent-host:${assignment.runId}:${assignment.attempt}`;
const execution = new AbortController();
let finished = false;
const renewLease = async () => {
try {
const response = await requestJson<{ lease?: { leaseId: string } }>(
`${credential.serverUrl}/agent-hosts/${encodeURIComponent(credential.workspaceId)}/${encodeURIComponent(credential.hostId)}/heartbeat`,
{
method: 'POST',
signal: AbortSignal.timeout(10_000),
headers: {
authorization: `AgentHost ${credential.secret}`,
'content-type': 'application/json',
},
body: JSON.stringify({
workspaceCwd: options.workspaceCwd,
providers: hostProviders(options.provider),
run: {
threadId: assignment.threadId,
runId: assignment.runId,
leaseId: assignment.lease.leaseId,
attempt: assignment.attempt,
},
}),
},
);
if (response.lease?.leaseId !== assignment.lease.leaseId) {
throw new Error(
'Coordinator did not confirm the run lease. Upgrade the coordinator.',
);
}
} catch (error) {
if (!finished) execution.abort(error);
}
};
await renewLease();
execution.signal.throwIfAborted();
const renew = setInterval(() => void renewLease(), LEASE_RENEW_MS);
const updates = new AbortController();
let stream: Promise<void> | undefined;
let progress = {
sequence: 1,
stage: 'starting',
detail: '执行器已接单,正在启动',
outputText: '',
thoughtText: '',
};
let sending = false;
const flush = async () => {
if (sending) return;
sending = true;
try {
await requestJson(
`${credential.serverUrl}/agent-hosts/${encodeURIComponent(credential.workspaceId)}/${encodeURIComponent(credential.hostId)}/progress`,
{
method: 'POST',
signal: AbortSignal.timeout(4000),
headers: {
authorization: `AgentHost ${credential.secret}`,
'content-type': 'application/json',
},
body: JSON.stringify({
...progress,
threadId: assignment.threadId,
runId: assignment.runId,
leaseId: assignment.lease.leaseId,
attempt: assignment.attempt,
}),
},
);
} catch {
// Telemetry is retried by the next heartbeat, never blocks execution.
} finally {
sending = false;
}
};
const report = (
stage: string,
detail: string,
outputText = progress.outputText,
thoughtText = progress.thoughtText,
) => {
// ponytail: bounded live preview; the final result retains the full answer.
progress = {
sequence: progress.sequence + 1,
stage,
detail: detail.slice(0, 1200),
outputText: outputText.slice(0, 262144),
thoughtText: thoughtText.slice(0, 65536),
};
};
const progressHeartbeat = setInterval(() => void flush(), 500);
progressHeartbeat.unref?.();
renew.unref?.();
let summary: string | undefined;
try {
if (providerFor(assignment, options.provider) === 'codex') {
const session = await codexHostSession(
path.join(Storage.getGlobalQwenDir(), 'agent-hosts', 'codex-sessions'),
[
credential.serverUrl,
credential.workspaceId,
credential.hostId,
options.workspaceCwd,
assignment.agent.id,
assignment.threadId,
],
);
if (session.threadId) report('resuming', '正在继续原 Codex 会话');
const messages = new Map<string, string>();
summary = await runCodexAppServer(
{
command: 'codex',
cwd: options.workspaceCwd,
session,
keepAlive: true,
onMessage: (id, text) => {
messages.set(id, text);
report(
'responding',
'正在回复',
[...messages.values()].join('\n\n'),
);
},
onActivity: report,
onThought: (delta) =>
report(
'thinking',
'Codex 正在思考',
undefined,
progress.thoughtText + delta,
),
},
modelPrompt(assignment),
'read-only',
execution.signal,
);
} else {
void flush();
const sessionId = agentThreadSessionId(
`${credential.hostId}:${assignment.agent.id}`,
assignment.threadId,
);
const sourceId = `${credential.hostId}:${assignment.agent.id}`;
const sessions = new SessionService(options.workspaceCwd);
const live = options.bridge
.listWorkspaceSessions(options.workspaceCwd)
.find((session) => session.sessionId === sessionId);
if (!live) {
const request = {
workspaceCwd: options.workspaceCwd,
sessionId,
sourceType: AGENT_HOST_SESSION_SOURCE_TYPE,
sourceId,
approvalMode: ApprovalMode.PLAN,
};
if (await sessions.sessionExists(sessionId)) {
await options.bridge.resumeSession(request);
} else {
await options.bridge.spawnOrAttach({
...request,
sessionScope: 'thread',
});
}
}
stream = streamAgentTurn(
options.bridge,
sessionId,
promptId,
AbortSignal.any([updates.signal, execution.signal]),
// ponytail: remote runs report stage and text only; forward `steps`
// through the lease update when remote step lists are wanted.
(update) =>
report(
update.stage,
update.detail ?? '',
update.outputText,
update.thoughtText,
),
).catch((error: unknown) => {
if (!updates.signal.aborted) execution.abort(error);
});
report('waiting', 'Qwen Code 已接单,等待模型回复');
await options.bridge.sendPrompt(
sessionId,
{
sessionId,
prompt: [{ type: 'text', text: assignment.prompt }],
},
execution.signal,
{ promptId, modelPrompt: modelPrompt(assignment) },
);
for (;;) {
execution.signal.throwIfAborted();
const turn = await options.bridge.getSessionTurnStatus(
sessionId,
undefined,
promptId,
);
if (turn?.promptId === promptId) {
if (turn.state === 'error' || turn.state === 'cancelled') {
throw new Error(turn.error?.message ?? 'Managed Agent cancelled.');
}
if (turn.state === 'completed') {
summary = turn.resultText?.trim();
break;
}
}
await delay(250, undefined, { signal: execution.signal });
}
}
execution.signal.throwIfAborted();
} catch (error) {
throw execution.signal.aborted ? execution.signal.reason : error;
} finally {
finished = true;
clearInterval(renew);
clearInterval(progressHeartbeat);
updates.abort();
await stream;
await flush();
}
if (!summary) {
throw new Error('Managed Agent finished without a final answer.');
}
return {
threadId: assignment.threadId,
runId: assignment.runId,
hostId: credential.hostId,
leaseId: assignment.lease.leaseId,
attempt: assignment.attempt,
status: 'completed',
close: { kind: 'review', summary },
};
}
async function returnResult(
serverUrl: string,
credential: AgentHostCredential,
result: HostRunResult,
): Promise<void> {
for (;;) {
try {
await requestJson(
`${serverUrl}/agent-hosts/${encodeURIComponent(credential.workspaceId)}/${encodeURIComponent(credential.hostId)}/result`,
{
method: 'POST',
headers: {
authorization: `AgentHost ${credential.secret}`,
'content-type': 'application/json',
},
body: JSON.stringify(result),
},
);
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === 'stale_lease' || message === 'attempt_moved_on') {
writeStderrLine(
`qwen serve: discarded managed Agent result (${message}).`,
);
return;
}
writeStderrLine(
`qwen serve: managed Agent result upload failed; retrying: ${message}`,
);
await delay(RETRY_MS);
}
}
}
const activeConnections = new Map<
string,
{ provider: string; start: Promise<void> }
>();
export async function startAgentHostConnection(
options: AgentHostConnectionOptions,
): Promise<void> {
const key = JSON.stringify([
normalizeServerUrl(options.serverUrl, options.allowHttp),
options.workspaceId,
options.workspaceCwd,
]);
const existing = activeConnections.get(key);
if (existing) {
if (existing.provider !== options.provider)
throw new Error(
'This workspace already has a Host connection using another provider.',
);
return existing.start;
}
const start = connectAgentHost(options);
activeConnections.set(key, { provider: options.provider, start });
try {
await start;
} catch (error) {
activeConnections.delete(key);
throw error;
}
}
async function connectAgentHost(
options: AgentHostConnectionOptions,
): Promise<void> {
const providers = hostProviders(options.provider);
const serverUrl = normalizeServerUrl(options.serverUrl, options.allowHttp);
if (
new URL(serverUrl).protocol === 'http:' &&
!isLoopbackBind(new URL(serverUrl).hostname)
) {
writeStderrLine(
'WARNING: Agent Host HTTP demo mode sends credentials, task content and results without encryption. Use only on a trusted network.',
);
}
const filePath = credentialPath(
serverUrl,
options.workspaceId,
options.workspaceCwd,
);
let credential = await readCredential(filePath);
if (!credential) {
if (!options.enrollmentToken) {
throw new Error(
'No saved Agent Host credential. Set QWEN_AGENT_HOST_ENROLLMENT_TOKEN once.',
);
}
const enrolled = await requestJson<{
host: { id: string };
secret: string;
}>(`${serverUrl}/agent-hosts/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: options.workspaceId,
token: options.enrollmentToken,
name: options.name?.trim() || os.hostname(),
workspaceCwd: options.workspaceCwd,
providers,
}),
});
credential = {
schemaVersion: 1,
serverUrl,
workspaceId: options.workspaceId,
hostId: enrolled.host.id,
secret: enrolled.secret,
};
await writeCredential(filePath, credential);
}
const activeCredential = credential;
let offline = false;
const heartbeat = async (): Promise<void> => {
try {
await requestJson(
`${serverUrl}/agent-hosts/${encodeURIComponent(activeCredential.workspaceId)}/${encodeURIComponent(activeCredential.hostId)}/heartbeat`,
{
method: 'POST',
headers: {
authorization: `AgentHost ${activeCredential.secret}`,
'content-type': 'application/json',
},
body: JSON.stringify({
workspaceCwd: options.workspaceCwd,
providers,
}),
},
);
if (offline) {
writeStderrLine(
`qwen serve: Agent Host ${activeCredential.hostId} reconnected.`,
);
}
offline = false;
} catch (error) {
if (!offline) {
writeStderrLine(
`qwen serve: Agent Host heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
offline = true;
}
};
await heartbeat();
if (offline) {
throw new Error(
'Agent Host could not confirm its connection to the coordinator. Check the callback URL and saved credential.',
);
}
const timer = setInterval(() => void heartbeat(), HEARTBEAT_MS);
timer.unref?.();
writeStderrLine(
`qwen serve: connected as Agent Host ${activeCredential.hostId} for ${options.workspaceId}.`,
);
void (async () => {
for (;;) {
try {
const assignment = await pickup(serverUrl, activeCredential);
if (!assignment) continue;
writeStderrLine(
`qwen serve: Agent Host ${activeCredential.hostId} running ${assignment.agent.name} on ${assignment.threadId}.`,
);
let result: HostRunResult;
try {
result = await executeAssignment(
options,
activeCredential,
assignment,
);
} catch (error) {
result = {
threadId: assignment.threadId,
runId: assignment.runId,
hostId: activeCredential.hostId,
leaseId: assignment.lease.leaseId,
attempt: assignment.attempt,
status:
error instanceof Error && error.message === 'not_leasable'
? 'cancelled'
: 'failed',
error: error instanceof Error ? error.message : String(error),
};
}
await returnResult(serverUrl, activeCredential, result);
} catch (error) {
writeStderrLine(
`qwen serve: Agent Host pickup failed: ${error instanceof Error ? error.message : String(error)}`,
);
await delay(RETRY_MS);
}
}
})();
}

View file

@ -1,39 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { parseJoinLink } from './agent-host-join.js';
describe('parseJoinLink', () => {
it('splits the coordinator, workspace and token out of the link', () => {
expect(
parseJoinLink('https://hub.local:4170/join/ws_1/Zk39-token_x'),
).toEqual({
serverUrl: 'https://hub.local:4170',
workspaceId: 'ws_1',
token: 'Zk39-token_x',
});
});
it('keeps a path prefix the coordinator is served under', () => {
expect(
parseJoinLink('http://10.0.0.2:4170/qwen/join/ws_1/t0k').serverUrl,
).toBe('http://10.0.0.2:4170/qwen');
});
it('refuses anything that is not a join link', () => {
for (const bad of [
'not a url',
'ftp://hub/join/ws_1/t',
'https://hub/join/ws_1',
'https://hub/join/ws_1/t/extra',
'https://hub/other/ws_1/t',
'https://hub/join/ws 1/t',
]) {
expect(() => parseJoinLink(bad)).toThrow(/--join/);
}
});
});

View file

@ -1,53 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The one-line join link a coordinator hands out under Agent → Runtime:
* `<coordinator URL>/join/<workspace id>/<enrollment token>`.
*
* It only ever travels to `qwen serve --join`; nothing serves that path. It
* bundles the three values `--agent-host-server`, `--agent-host-workspace-id`
* and QWEN_AGENT_HOST_ENROLLMENT_TOKEN used to be typed separately. The token
* is single-use and short-lived, and the host saves its own credential after
* enrolling, so the link is spent once it has been used.
*/
export interface AgentHostJoinTarget {
serverUrl: string;
workspaceId: string;
token: string;
}
const SEGMENT = /^[A-Za-z0-9_-]{1,256}$/;
export function parseJoinLink(link: string): AgentHostJoinTarget {
let url: URL;
try {
url = new URL(link.trim());
} catch {
throw new Error('--join expects the link shown by the coordinator.');
}
const marker = url.pathname.lastIndexOf('/join/');
const [workspaceId, token, ...rest] =
marker >= 0 ? url.pathname.slice(marker + '/join/'.length).split('/') : [];
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
!workspaceId ||
!token ||
rest.some(Boolean) ||
!SEGMENT.test(workspaceId) ||
!SEGMENT.test(token)
) {
throw new Error(
'--join expects a link like https://host:4170/join/<workspace>/<token>.',
);
}
const base = url.pathname.slice(0, marker).replace(/\/+$/, '');
return {
serverUrl: `${url.origin}${base}`,
workspaceId,
token,
};
}

View file

@ -742,33 +742,14 @@ describe('serve fast path argument parsing', () => {
['--external-tool-guard-timeout-ms', '3000'],
],
['channel', ['--channel', 'telegram']],
// The managed Agent Host worker flags. A Host runs its own `qwen serve`
// that only reaches out, so these have to be reachable the same way
// every other serve option is.
['agent-host-server', ['--agent-host-server', 'https://example.invalid']],
['agent-host-workspace-id', ['--agent-host-workspace-id', 'ws_1']],
['agent-host-name', ['--agent-host-name', 'builder']],
['agent-host-provider', ['--agent-host-provider', 'codex']],
['agent-host-allow-http', ['--agent-host-allow-http']],
['join', ['--join', 'https://example.invalid/join/ws_1/token']],
['help', ['--help']],
['version', ['--version']],
]);
const expectedFallbackOptions = new Set([
// The fast path exists to start a plain daemon without loading the full
// CLI. A managed Agent Host is a different mode — it enrols, polls and
// launches an executor — so these hand off rather than being taught to
// the fast path.
'agent-host-allow-http',
'agent-host-name',
'agent-host-provider',
'agent-host-server',
'agent-host-workspace-id',
'channel',
'external-tool-guard-endpoint',
'external-tool-guard-mode',
'external-tool-guard-timeout-ms',
'join',
'help',
'local-control',
'local-control-address',

View file

@ -308,8 +308,8 @@ const allowedProcessEnvAccesses = normalizeAllowances([
reason:
'Embedded server construction keeps a process-environment compatibility fallback. ' +
'The collaboration opt-in is read once at daemon startup and is process-scoped ' +
'by design: it governs work no session owns (a recovery sweep, the dispatch ' +
'timer, the Host transport routes), so it cannot be a per-session setting.',
'by design: it governs work no session owns (a recovery sweep and the ' +
'dispatch timer), so it cannot be a per-session setting.',
accesses: { whole: 1, 'key:QWEN_CODE_ENABLE_AGENT_COLLABORATION': 1 },
},
],

View file

@ -1,485 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
AgentCard,
Role,
TaskState,
taskStateFromJSON,
type ListTasksResponse,
type StreamResponse,
type Task,
} from '@a2a-js/sdk';
import {
JsonRpcRequestMalformedError,
JsonRpcTaskNotFoundError,
JsonRpcUnsupportedOperationError,
} from '@a2a-js/sdk/errors';
import {
UnauthenticatedUser,
type A2ARequestHandler,
type ServerCallContext,
type User,
} from '@a2a-js/sdk/server';
import { jsonRpcHandler } from '@a2a-js/sdk/server/express';
import type {
A2AAgentCard,
A2ACaller,
A2AFailure,
A2ATaskView,
} from '@qwen-code/qwen-code-core';
import {
A2A_AGENT_CARD_PATH,
A2A_CONTENT_TYPE,
A2A_PROTOCOL_VERSION,
A2A_TRANSPORT_BINDING,
QWEN_A2A_EXTENSION_URI,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/a2a-contract.js';
import {
a2aAgentCardForCaller,
a2aCancelTask,
a2aGetTask,
a2aListTasks,
a2aSendMessage,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/a2a-server.js';
import { checkA2AGrant } from '@qwen-code/qwen-code-core/agents/workspace-agents/a2a-grants.js';
import type { Application, NextFunction, Request, Response } from 'express';
import type { WorkspaceRegistry } from '../workspace-registry.js';
const A2A_PATH = '/a2a/v1';
const REQUEST_REFUSED = -32010;
const IDEMPOTENCY_CONFLICT = -32011;
const HEADER_WORKSPACE = 'x-qwen-workspace-id';
const HEADER_CALLER = 'x-qwen-caller-id';
const HEADER_AGENT = 'x-qwen-agent-id';
class AuthenticatedA2AUser implements User {
readonly isAuthenticated = true;
constructor(
readonly projectRoot: string,
readonly caller: A2ACaller,
readonly agentId: string,
readonly baseUrl: string,
) {}
get userName(): string {
return this.caller.callerId;
}
}
function runtimeFor(registry: WorkspaceRegistry, workspaceId: string) {
return registry
.listAll()
.find((runtime) => runtime.workspaceId === workspaceId);
}
function baseUrl(req: Request): string {
return `${req.protocol}://${req.get('host') ?? '127.0.0.1'}`;
}
async function buildUser(
req: Request,
registry: WorkspaceRegistry,
): Promise<User> {
const authorization = /^Bearer ([A-Za-z0-9_-]{32,})$/.exec(
req.get('authorization') ?? '',
);
const workspaceId = req.get(HEADER_WORKSPACE);
const callerId = req.get(HEADER_CALLER);
const agentId = req.get(HEADER_AGENT);
const runtime = workspaceId ? runtimeFor(registry, workspaceId) : undefined;
if (
!authorization ||
!callerId ||
!agentId ||
!runtime ||
(!runtime.primary && !runtime.trusted)
) {
return new UnauthenticatedUser();
}
const caller = { callerId, secret: authorization[1] };
const grant = await checkA2AGrant(runtime.workspaceCwd, {
...caller,
agentId,
required: 'analysis',
});
if (!grant.ok) return new UnauthenticatedUser();
return new AuthenticatedA2AUser(
runtime.workspaceCwd,
caller,
agentId,
baseUrl(req),
);
}
function authenticated(context: ServerCallContext): AuthenticatedA2AUser {
if (!(context.user instanceof AuthenticatedA2AUser)) {
throw new JsonRpcRequestMalformedError({
envelopeCode: REQUEST_REFUSED,
message: 'Request refused.',
});
}
return context.user;
}
function fail(failure: A2AFailure): never {
switch (failure.kind) {
case 'invalid':
throw new JsonRpcRequestMalformedError({ message: failure.detail });
case 'not_found':
throw new JsonRpcTaskNotFoundError({ message: 'Task not found.' });
case 'refused':
throw new JsonRpcRequestMalformedError({
envelopeCode: REQUEST_REFUSED,
message: 'Request refused.',
});
case 'conflict':
throw new JsonRpcRequestMalformedError({
envelopeCode: IDEMPOTENCY_CONFLICT,
message: `Message id was already used for different content. Existing task: ${failure.existingTaskId}.`,
metadata: { existingTaskId: failure.existingTaskId },
});
default: {
// `A2AFailure` is a closed union, so this is unreachable today. It is
// here so that adding a member is a compile error at the one place that
// decides what a caller is told, rather than a silent fall-through that
// returns success for a failure.
const unreachable: never = failure;
throw new Error(`Unmapped A2A failure: ${JSON.stringify(unreachable)}`);
}
}
}
function unwrap<T>(
result: { ok: true; value: T } | ({ ok: false } & A2AFailure),
): T {
return result.ok ? result.value : fail(result);
}
function task(view: A2ATaskView): Task {
return {
id: view.id,
contextId: view.contextId,
status: {
state: taskStateFromJSON(view.status.state),
message: undefined,
timestamp: view.status.timestamp,
},
artifacts: [],
history: [],
metadata: view.metadata,
};
}
function securityRequirements() {
return [
{
schemes: {
bearer: { list: [] },
workspace: { list: [] },
caller: { list: [] },
agent: { list: [] },
},
},
];
}
function card(source: A2AAgentCard): AgentCard {
const requirements = securityRequirements();
return {
name: source.name,
description: source.description,
supportedInterfaces: source.interfaces.map((entry) => ({
url: entry.url,
protocolBinding: entry.protocolBinding,
protocolVersion: source.protocolVersion,
tenant: '',
})),
provider: undefined,
version: '1.0.0',
capabilities: {
streaming: source.capabilities.streaming,
pushNotifications: source.capabilities.pushNotifications,
extendedAgentCard: source.capabilities.extendedAgentCard,
extensions: source.capabilities.extensions.map((extension) => ({
...extension,
params: undefined,
})),
},
securitySchemes: {
bearer: {
scheme: {
$case: 'httpAuthSecurityScheme',
value: {
description: 'Opaque A2A grant secret',
scheme: 'Bearer',
bearerFormat: 'opaque',
},
},
},
workspace: {
scheme: {
$case: 'apiKeySecurityScheme',
value: {
description: 'Workspace containing the target agent',
location: 'header',
name: HEADER_WORKSPACE,
},
},
},
caller: {
scheme: {
$case: 'apiKeySecurityScheme',
value: {
description: 'Stable external caller id',
location: 'header',
name: HEADER_CALLER,
},
},
},
agent: {
scheme: {
$case: 'apiKeySecurityScheme',
value: {
description: 'Agent addressed by this grant',
location: 'header',
name: HEADER_AGENT,
},
},
},
},
securityRequirements: requirements,
defaultInputModes: ['text/plain'],
defaultOutputModes: ['text/plain'],
skills: source.skills.map((skill) => ({
...skill,
tags: [],
examples: [],
inputModes: ['text/plain'],
outputModes: ['text/plain'],
securityRequirements: requirements,
})),
signatures: [],
};
}
function publicCard(origin: string): AgentCard {
return card({
protocolVersion: A2A_PROTOCOL_VERSION,
name: 'Qwen Code workspace agents',
description: 'Workspace agents collaborating on shared task threads',
interfaces: [
{
url: `${origin}${A2A_PATH}`,
protocolBinding: A2A_TRANSPORT_BINDING,
},
],
capabilities: {
streaming: false,
pushNotifications: false,
extendedAgentCard: true,
extensions: [
{
uri: QWEN_A2A_EXTENSION_URI,
description: 'Carries Qwen Code thread state and known token usage',
required: false,
},
],
},
skills: [],
});
}
function messageText(params: Parameters<A2ARequestHandler['sendMessage']>[0]) {
const message = params.message;
if (!message || message.role !== Role.ROLE_USER || !message.messageId) {
fail({
kind: 'invalid',
detail: 'A user message with messageId is required.',
});
}
if (
message.parts.length === 0 ||
message.parts.some((part) => part.content?.$case !== 'text')
) {
fail({ kind: 'invalid', detail: 'Only text message parts are supported.' });
}
return {
messageId: message.messageId,
body: message.parts
.map((part) => (part.content?.$case === 'text' ? part.content.value : ''))
.join('\n'),
};
}
function extensionMetadata(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function unsupported(): JsonRpcUnsupportedOperationError {
return new JsonRpcUnsupportedOperationError({
message: 'This optional operation is not supported.',
});
}
function requestHandler(_registry: WorkspaceRegistry): A2ARequestHandler {
return {
getAgentCard: async () => publicCard('http://localhost'),
getAuthenticatedExtendedAgentCard: async (_params, context) => {
const user = authenticated(context);
const extended = await a2aAgentCardForCaller(
user.projectRoot,
user.caller,
[user.agentId],
user.baseUrl,
);
if (extended.skills.length === 0) {
fail({ kind: 'refused' });
}
return card(extended);
},
sendMessage: async (params, context) => {
const user = authenticated(context);
const input = messageText(params);
const metadata = extensionMetadata(
params.metadata?.[QWEN_A2A_EXTENSION_URI],
);
const title =
typeof metadata['title'] === 'string'
? metadata['title']
: input.body.slice(0, 80);
const acceptanceCriteria = metadata['acceptanceCriteria'];
return task(
unwrap(
await a2aSendMessage(user.projectRoot, user.caller, {
agentId: user.agentId,
messageId: input.messageId,
title,
body: input.body,
...(typeof acceptanceCriteria === 'string'
? { acceptanceCriteria }
: {}),
}),
),
);
},
getTask: async (params, context) => {
const user = authenticated(context);
return task(
unwrap(await a2aGetTask(user.projectRoot, user.caller, params.id)),
);
},
listTasks: async (params, context): Promise<ListTasksResponse> => {
const user = authenticated(context);
let tasks = unwrap(
await a2aListTasks(user.projectRoot, user.caller, user.agentId),
).map(task);
if (params.contextId) {
tasks = tasks.filter((entry) => entry.contextId === params.contextId);
}
if (params.status !== TaskState.TASK_STATE_UNSPECIFIED) {
tasks = tasks.filter((entry) => entry.status?.state === params.status);
}
const totalSize = tasks.length;
const pageSize = params.pageSize ?? 50;
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
fail({ kind: 'invalid', detail: 'Invalid pageSize.' });
}
const offset = params.pageToken ? Number(params.pageToken) : 0;
if (!Number.isSafeInteger(offset) || offset < 0) {
fail({ kind: 'invalid', detail: 'Invalid pageToken.' });
}
const page = tasks.slice(offset, offset + pageSize);
return {
tasks: page,
nextPageToken:
offset + page.length < totalSize ? String(offset + page.length) : '',
pageSize,
totalSize,
};
},
cancelTask: async (params, context) => {
const user = authenticated(context);
const cancelled = unwrap(
await a2aCancelTask(user.projectRoot, user.caller, params.id),
);
const result = task(cancelled.task);
const metadata = extensionMetadata(
result.metadata?.[QWEN_A2A_EXTENSION_URI],
);
result.metadata = {
...result.metadata,
[QWEN_A2A_EXTENSION_URI]: {
...metadata,
runsStillLive: cancelled.runsStillLive,
},
};
return result;
},
// Declared with the generator signature the interface requires, but it
// refuses before yielding: the capability is advertised false, so a client
// that follows the card never calls it, and one that ignores the card is
// told rather than left waiting on a stream that will not come.
sendMessageStream(): AsyncGenerator<StreamResponse, void, undefined> {
throw unsupported();
},
createTaskPushNotificationConfig: async () => {
throw unsupported();
},
getTaskPushNotificationConfig: async () => {
throw unsupported();
},
listTaskPushNotificationConfigs: async () => {
throw unsupported();
},
deleteTaskPushNotificationConfig: async () => {
throw unsupported();
},
resubscribe(): AsyncGenerator<StreamResponse, void, undefined> {
throw unsupported();
},
};
}
export function registerA2ATransportRoutes(
app: Application,
workspaceRegistry: WorkspaceRegistry,
): void {
app.get(`/${A2A_AGENT_CARD_PATH}`, (req: Request, res: Response): void => {
res.setHeader('A2A-Version', A2A_PROTOCOL_VERSION);
res.setHeader('Content-Type', A2A_CONTENT_TYPE);
res
.status(200)
.send(JSON.stringify(AgentCard.toJSON(publicCard(baseUrl(req)))));
});
app.use(
A2A_PATH,
(req: Request, res: Response, next: NextFunction): void => {
if (req.is(A2A_CONTENT_TYPE)) {
req.headers['content-type'] = 'application/json';
}
res.setHeader('A2A-Version', A2A_PROTOCOL_VERSION);
res.setHeader('Content-Type', A2A_CONTENT_TYPE);
next();
},
jsonRpcHandler({
requestHandler: requestHandler(workspaceRegistry),
userBuilder: (req) => buildUser(req, workspaceRegistry),
}),
);
}

View file

@ -1,158 +0,0 @@
import type { Application, Request, RequestHandler, Response } from 'express';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { issueAgentHostEnrollment } from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
import { startAgentHostConnection } from '../agent-host-client.js';
import { isLoopbackBind } from '../loopback-binds.js';
import type { WorkspaceRuntime } from '../workspace-registry.js';
function serverUrl(value: unknown, allowHttp: boolean): string {
if (typeof value !== 'string') throw new Error('请填写服务地址。');
const url = new URL(value);
if (
url.username ||
url.password ||
url.search ||
url.hash ||
(url.protocol !== 'https:' &&
!(
url.protocol === 'http:' &&
(allowHttp || isLoopbackBind(url.hostname))
))
) {
throw new Error(
'请使用 HTTPS;可信演示网络可显式允许 HTTP。地址不能包含凭证或查询参数。',
);
}
return url.toString().replace(/\/+$/, '');
}
async function providers(): Promise<string[]> {
try {
await promisify(execFile)('codex', ['--version'], { timeout: 5000 });
return ['qwen', 'codex'];
} catch {
return ['qwen'];
}
}
export function registerAgentHostConnectionRoutes(
app: Application,
prefix: string,
runtimeFor: (req: Request, res: Response) => WorkspaceRuntime | undefined,
mutate: () => RequestHandler,
): void {
app.get(`${prefix}/hosts/service`, async (req, res) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
res.json({
protocol: 1,
workspaceCwd: runtime.workspaceCwd,
providers: await providers(),
});
});
app.post(`${prefix}/hosts/connect`, mutate(), async (req, res) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
try {
const input = req.body ?? {};
const url = serverUrl(input.serverUrl, input.allowHttp === true);
if (
typeof input.workspaceId !== 'string' ||
!input.workspaceId ||
typeof input.enrollmentToken !== 'string' ||
!input.enrollmentToken ||
!['qwen', 'codex'].includes(input.provider)
) {
throw new Error('接入参数不完整。');
}
if (!(await providers()).includes(input.provider))
throw new Error('此服务环境未安装所选执行程序。');
await startAgentHostConnection({
bridge: runtime.bridge,
workspaceCwd: runtime.workspaceCwd,
serverUrl: url,
workspaceId: input.workspaceId,
enrollmentToken: input.enrollmentToken,
provider: input.provider,
allowHttp: input.allowHttp === true,
});
res.json({
connected: true,
workspaceCwd: runtime.workspaceCwd,
provider: input.provider,
});
} catch (error) {
res
.status(400)
.json({ error: error instanceof Error ? error.message : '接入失败。' });
}
});
app.post(`${prefix}/hosts/remote-connect`, mutate(), async (req, res) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
try {
const input = req.body ?? {};
const remote = serverUrl(input.remoteUrl, input.allowHttp === true);
const callback = serverUrl(input.serverUrl, input.allowHttp === true);
if (
typeof input.remoteCwd !== 'string' ||
!input.remoteCwd.trim() ||
typeof input.remoteToken !== 'string' ||
!input.remoteToken.trim() ||
!['qwen', 'codex'].includes(input.provider)
)
throw new Error('请填写远程服务凭证、远程执行目录和执行程序。');
const endpoint = `${remote}/workspaces/${encodeURIComponent(input.remoteCwd)}/agent/hosts`;
const request = async (path: string, body?: unknown) => {
const response = await fetch(`${endpoint}${path}`, {
method: body ? 'POST' : 'GET',
headers: {
authorization: `Bearer ${input.remoteToken}`,
'content-type': 'application/json',
},
...(body ? { body: JSON.stringify(body) } : {}),
redirect: 'error',
signal: AbortSignal.timeout(20000),
});
if (response.status === 404)
throw new Error(
'远程服务不支持在线主机接入,或执行目录未注册。请升级并启用协作功能,确认远程工作区后重试。',
);
if (response.status === 401 || response.status === 403)
throw new Error('远程凭证无效,或远程项目尚未授权。');
if (!response.ok)
throw new Error(
`远程接入失败(${response.status}),请检查远程服务日志及协调端回连地址。`,
);
return (await response.json()) as {
protocol?: number;
providers?: string[];
connected?: boolean;
};
};
const service = await request('/service');
if (
service.protocol !== 1 ||
!service.providers?.includes(input.provider)
)
throw new Error('远程服务不支持所选执行程序或接入协议。');
const enrollment = await issueAgentHostEnrollment(runtime.workspaceCwd);
const result = await request('/connect', {
serverUrl: callback,
workspaceId: runtime.workspaceId,
enrollmentToken: enrollment.token,
provider: input.provider,
allowHttp: input.allowHttp === true,
});
if (!result.connected) throw new Error('远程服务未确认接入。');
res.json(result);
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : '无法连接远程服务。',
});
}
});
}

View file

@ -1,398 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import { setTimeout as delay } from 'node:timers/promises';
import type { Application, Request, Response } from 'express';
import type { HostRunResult } from '@qwen-code/qwen-code-core';
import {
applyHostRunResult,
reportHostRunProgress,
pickupRunForHost,
renewRunLease,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/host-lease.js';
import {
authenticateAgentHost,
enrollAgentHost,
heartbeatAgentHost,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
function body(req: Request): Record<string, unknown> {
return typeof req.body === 'object' && req.body !== null ? req.body : {};
}
function runtimeFor(registry: WorkspaceRegistry, workspaceId: string) {
return registry
.listAll()
.find((runtime) => runtime.workspaceId === workspaceId);
}
function hostSecret(req: Request): string | undefined {
const match = /^AgentHost ([A-Za-z0-9_-]{32,})$/.exec(
req.get('authorization') ?? '',
);
return match?.[1];
}
function readWaitMs(value: unknown): number | undefined {
if (value === undefined) return 25_000;
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= 0 &&
value <= 25_000
? value
: undefined;
}
function readHostResult(
input: Record<string, unknown>,
hostId: string,
): HostRunResult | undefined {
const threadId = input['threadId'];
const runId = input['runId'];
const leaseId = input['leaseId'];
const attempt = input['attempt'];
const status = input['status'];
const error = input['error'];
const rawClose = input['close'];
if (
typeof threadId !== 'string' ||
typeof runId !== 'string' ||
typeof leaseId !== 'string' ||
typeof attempt !== 'number' ||
!Number.isInteger(attempt) ||
attempt < 1 ||
(status !== 'completed' && status !== 'failed' && status !== 'cancelled') ||
(error !== undefined && typeof error !== 'string')
) {
return undefined;
}
let close: HostRunResult['close'];
if (rawClose !== undefined) {
if (typeof rawClose !== 'object' || rawClose === null) return undefined;
const value = rawClose as Record<string, unknown>;
if (
value['kind'] === 'review' &&
typeof value['summary'] === 'string' &&
value['summary'].trim()
) {
close = { kind: 'review', summary: value['summary'].trim() };
} else {
return undefined;
}
}
if (status !== 'completed' && close !== undefined) return undefined;
return {
threadId,
runId,
hostId,
leaseId,
attempt,
status,
...(close ? { close } : {}),
...(error ? { error } : {}),
};
}
export function registerAgentHostTransportRoutes(
app: Application,
workspaceRegistry: WorkspaceRegistry,
): void {
const json = express.json({ limit: '16kb' });
app.post(
'/agent-hosts/:workspaceId/:hostId/progress',
express.json({ limit: '2mb' }),
async (req, res) => {
const { workspaceId, hostId } = req.params;
const secret = hostSecret(req);
const runtime = runtimeFor(workspaceRegistry, workspaceId);
if (!runtime || (!runtime.primary && !runtime.trusted)) {
res.status(404).json({ error: 'Workspace not found.' });
return;
}
if (
!secret ||
!(await authenticateAgentHost(runtime.workspaceCwd, hostId, secret))
) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
const {
threadId,
runId,
leaseId,
attempt,
sequence,
stage,
detail,
outputText,
thoughtText,
} = body(req);
if (
typeof threadId !== 'string' ||
typeof runId !== 'string' ||
typeof leaseId !== 'string' ||
typeof attempt !== 'number' ||
!Number.isSafeInteger(attempt) ||
attempt < 1 ||
typeof sequence !== 'number' ||
!Number.isSafeInteger(sequence) ||
sequence < 1 ||
typeof stage !== 'string' ||
!['starting', 'waiting', 'thinking', 'tool', 'responding'].includes(
stage,
) ||
typeof detail !== 'string' ||
detail.length > 1200 ||
(outputText !== undefined &&
(typeof outputText !== 'string' || outputText.length > 262144)) ||
(thoughtText !== undefined &&
(typeof thoughtText !== 'string' || thoughtText.length > 65536))
) {
res.status(400).json({ error: 'Invalid progress.' });
return;
}
const result = await reportHostRunProgress(runtime.workspaceCwd, {
threadId,
runId,
hostId,
leaseId,
attempt,
sequence,
stage,
detail,
outputText,
thoughtText,
});
res.status(result.ok ? 200 : 409).json(result);
},
);
app.post('/agent-hosts/enroll', json, async (req: Request, res: Response) => {
const input = body(req);
const workspaceId = input['workspaceId'];
const token = input['token'];
const name = input['name'];
const workspaceCwd = input['workspaceCwd'];
const providers = input['providers'];
if (
typeof workspaceId !== 'string' ||
typeof token !== 'string' ||
typeof name !== 'string' ||
typeof workspaceCwd !== 'string' ||
!Array.isArray(providers) ||
!providers.every((provider) => typeof provider === 'string')
) {
res.status(400).json({ error: 'Invalid Agent Host enrollment.' });
return;
}
const runtime = runtimeFor(workspaceRegistry, workspaceId);
if (!runtime) {
res.status(404).json({ error: 'Workspace not found.' });
return;
}
if (!runtime.primary && !runtime.trusted) {
res.status(403).json({ error: 'Workspace is not trusted.' });
return;
}
try {
const enrolled = await enrollAgentHost(runtime.workspaceCwd, {
token,
name,
workspaceCwd,
providers,
});
res.status(201).json(enrolled);
} catch (error) {
res.status(401).json({
error: error instanceof Error ? error.message : String(error),
});
}
});
app.post(
'/agent-hosts/:workspaceId/:hostId/heartbeat',
json,
async (req: Request, res: Response) => {
const workspaceId = req.params['workspaceId'];
const hostId = req.params['hostId'];
const secret = hostSecret(req);
const input = body(req);
const workspaceCwd = input['workspaceCwd'];
const providers = input['providers'];
if (
!workspaceId ||
!hostId ||
!secret ||
typeof workspaceCwd !== 'string' ||
!Array.isArray(providers) ||
!providers.every((provider) => typeof provider === 'string')
) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
const runtime = runtimeFor(workspaceRegistry, workspaceId);
if (!runtime || (!runtime.primary && !runtime.trusted)) {
res.status(404).json({ error: 'Workspace not found.' });
return;
}
try {
const host = await heartbeatAgentHost(
runtime.workspaceCwd,
hostId,
secret,
{ workspaceCwd, providers },
);
if (!host) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
if (input['run'] !== undefined) {
const run = input['run'];
if (!run || typeof run !== 'object' || Array.isArray(run)) {
res.status(400).json({ error: 'Invalid Agent Host lease.' });
return;
}
const { threadId, runId, leaseId, attempt } = run as Record<
string,
unknown
>;
if (
typeof threadId !== 'string' ||
typeof runId !== 'string' ||
typeof leaseId !== 'string' ||
typeof attempt !== 'number' ||
!Number.isSafeInteger(attempt) ||
attempt < 1
) {
res.status(400).json({ error: 'Invalid Agent Host lease.' });
return;
}
const renewed = await renewRunLease(runtime.workspaceCwd, {
threadId,
runId,
leaseId,
attempt,
hostId,
});
if (!renewed.ok) {
res.status(409).json({ error: renewed.reason });
return;
}
res.json({ host, lease: renewed.value });
return;
}
res.json({ host });
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
app.post(
'/agent-hosts/:workspaceId/:hostId/pickup',
json,
async (req: Request, res: Response) => {
const workspaceId = req.params['workspaceId'];
const hostId = req.params['hostId'];
const secret = hostSecret(req);
const waitMs = readWaitMs(body(req)['waitMs']);
if (!workspaceId || !hostId || !secret) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
if (waitMs === undefined) {
res.status(400).json({ error: 'Invalid Agent Host pickup.' });
return;
}
const runtime = runtimeFor(workspaceRegistry, workspaceId);
if (!runtime || (!runtime.primary && !runtime.trusted)) {
res.status(404).json({ error: 'Workspace not found.' });
return;
}
if (
!(await authenticateAgentHost(runtime.workspaceCwd, hostId, secret))
) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
try {
const deadline = Date.now() + waitMs;
for (;;) {
const assignment = await pickupRunForHost(
runtime.workspaceCwd,
hostId,
);
if (assignment) {
res.json({ assignment });
return;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
res.status(204).end();
return;
}
await delay(Math.min(250, remaining));
}
} catch (error) {
res.status(409).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
app.post(
'/agent-hosts/:workspaceId/:hostId/result',
json,
async (req: Request, res: Response) => {
const workspaceId = req.params['workspaceId'];
const hostId = req.params['hostId'];
const secret = hostSecret(req);
if (!workspaceId || !hostId || !secret) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
const runtime = runtimeFor(workspaceRegistry, workspaceId);
if (!runtime || (!runtime.primary && !runtime.trusted)) {
res.status(404).json({ error: 'Workspace not found.' });
return;
}
if (
!(await authenticateAgentHost(runtime.workspaceCwd, hostId, secret))
) {
res.status(401).json({ error: 'Invalid Agent Host credential.' });
return;
}
const input = readHostResult(body(req), hostId);
if (!input) {
res.status(400).json({ error: 'Invalid Agent Host result.' });
return;
}
try {
const result = await applyHostRunResult(runtime.workspaceCwd, input);
if (!result.ok) {
const status = result.reason === 'no_such_run' ? 404 : 409;
res.status(status).json({ error: result.reason });
return;
}
res.json({
threadId: result.value.thread.id,
status: result.value.thread.status,
alreadyApplied: result.value.alreadyApplied,
});
} catch (error) {
res.status(409).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
}

View file

@ -24,12 +24,10 @@
* the shell; there is no field they can set to claim otherwise.
*/
import { randomBytes } from 'node:crypto';
import type { Application, Request, RequestHandler, Response } from 'express';
import type {
ThreadPriority,
WorkspaceAgent,
WorkspaceAgentExecution,
Thread,
ThreadRun,
} from '@qwen-code/qwen-code-core';
@ -44,25 +42,20 @@ import {
generateEventId,
isValidAgentName,
listThreads,
issueAgentHostEnrollment,
readAgentHosts,
readWorkspaceAgents,
readAgentWorkspace,
readThread,
releaseAgentHostSession,
retireWorkspaceAgent,
isAgentAddressable,
isAgentLocal,
maxConcurrentRunsFor,
setWorkspaceAgentEnabled,
setWorkspaceAgentExecution,
updateWorkspaceAgents,
withAgentStoreTransaction,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
import {
THREAD_PRIORITY_ORDER,
DEFAULT_THREAD_PRIORITY,
LOCAL_AGENT_RUNTIME_ID,
HUMAN_AUTHOR_ID,
DEFAULT_THREAD_AUTO_TURN_BUDGET,
DEFAULT_THREAD_TOKEN_BUDGET,
@ -82,20 +75,13 @@ import {
AGENT_TOOL_CLASSIFICATION,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/capability.js';
import { resolveThreadStatus } from '@qwen-code/qwen-code-core/agents/workspace-agents/thread-status.js';
import {
issueA2AGrant,
listA2AGrants,
revokeA2AGrant,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/a2a-grants.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { AGENT_SESSION_SOURCE_TYPE } from '../../runtime/agent-session-source.js';
import { startAgentHostSessionOwner } from '../workspace-agents/agent-host-session.js';
import {
AGENT_HOST_ONLINE_WINDOW_MS,
subscribeAgentEvents,
type AgentLiveEvent,
} from '../workspace-agents/agent-events.js';
import { registerAgentHostConnectionRoutes } from './agent-host-connection.js';
import {
requireTrustedWorkspaceRuntime,
resolveWorkspaceRuntimeFromParam,
@ -212,33 +198,6 @@ function readAgentConfigPatch(payload: {
};
}
function readAgentExecution(
value: unknown,
): WorkspaceAgentExecution | undefined | 'invalid' {
if (value === undefined) return undefined;
if (typeof value !== 'object' || value === null) return 'invalid';
const input = value as Record<string, unknown>;
if (input['mode'] === 'local') return { mode: 'local' };
const hostIds = input['hostIds'];
const provider = input['provider'];
if (
input['mode'] !== 'managed-host' ||
!Array.isArray(hostIds) ||
hostIds.length === 0 ||
!hostIds.every(
(hostId) => typeof hostId === 'string' && hostId.length > 0,
) ||
(provider !== undefined && provider !== 'qwen' && provider !== 'codex')
) {
return 'invalid';
}
return {
mode: 'managed-host',
hostIds: [...new Set(hostIds)],
...(provider ? { provider } : {}),
};
}
/**
* The most threads one agent may be set to work at once.
*
@ -346,8 +305,6 @@ export function registerWorkspaceAgentRoutes(
return runtime;
};
registerAgentHostConnectionRoutes(app, prefix, runtimeFor, deps.mutate);
const dispatch = async (runtime: WorkspaceRuntime): Promise<void> => {
runtime.generationGuard?.assertOpen();
let current = owners.get(runtime.workspaceCwd);
@ -522,105 +479,14 @@ export function registerWorkspaceAgentRoutes(
if (!runtime) return;
const root = runtime.workspaceCwd;
try {
const [agents, { threads }, workspace, hosts] = await Promise.all([
const [agents, { threads }] = await Promise.all([
readWorkspaceAgents(root),
listThreads(root),
readAgentWorkspace(root),
readAgentHosts(root),
]);
const sessions = runtime.bridge.listWorkspaceSessions(root);
const agentSessions = sessions.filter(
(candidate) => candidate.sourceType === AGENT_SESSION_SOURCE_TYPE,
);
const lastSeenAt = workspace.hostSessionId
? runtime.bridge.getHeartbeatState(workspace.hostSessionId)
?.sessionLastSeenAt
: undefined;
const localAgentIds = new Set(
agents
.filter(
(agent) => agent.retiredAt === undefined && isAgentLocal(agent),
)
.map((agent) => agent.id),
);
const localRuntime = {
id: LOCAL_AGENT_RUNTIME_ID,
kind: 'local' as const,
label: 'Local daemon',
provider: 'Qwen Code ACP',
status: 'online' as const,
workspaceId: runtime.workspaceId,
workspaceCwd: root,
...(workspace.hostSessionId
? { hostSessionId: workspace.hostSessionId }
: {}),
...(lastSeenAt !== undefined ? { lastSeenAt } : {}),
agentCount: localAgentIds.size,
sessionCount: agentSessions.length,
runningTaskCount: threads.filter((thread) =>
thread.runs.some(
(run) =>
localAgentIds.has(run.agentId) &&
ACTIVE_RUN_STATUSES.has(run.status),
),
).length,
queuedTaskCount: threads.reduce(
(count, thread) =>
count +
thread.runs.filter(
(run) =>
localAgentIds.has(run.agentId) && run.status === 'queued',
).length,
0,
),
};
const now = Date.now();
const hostRuntimes = hosts.map((host) => {
const agentIds = new Set(
agents
.filter(
(agent) =>
agent.retiredAt === undefined &&
agent.execution?.mode === 'managed-host' &&
agent.execution.hostIds.includes(host.id),
)
.map((agent) => agent.id),
);
return {
id: host.id,
kind: 'external' as const,
label: host.name,
provider: host.providers.join(', '),
status:
host.lastSeenAt !== undefined &&
now - host.lastSeenAt <= AGENT_HOST_ONLINE_WINDOW_MS
? ('online' as const)
: ('offline' as const),
workspaceId: runtime.workspaceId,
workspaceCwd: host.workspaceCwd,
...(host.lastSeenAt !== undefined
? { lastSeenAt: host.lastSeenAt }
: {}),
agentCount: agentIds.size,
sessionCount: 0,
runningTaskCount: threads.filter((thread) =>
thread.runs.some(
(run) =>
agentIds.has(run.agentId) &&
run.lease?.hostId === host.id &&
ACTIVE_RUN_STATUSES.has(run.status),
),
).length,
queuedTaskCount: threads.reduce(
(count, thread) =>
count +
thread.runs.filter(
(run) => agentIds.has(run.agentId) && run.status === 'queued',
).length,
0,
),
};
});
res.json({
agents: agents.map((agent) => {
const active = threads.find((thread) =>
@ -644,21 +510,6 @@ export function registerWorkspaceAgentRoutes(
const sessionsForAgent = agentSessions.filter(
(candidate) => candidate.sourceId === agent.id,
);
const execution = agent.execution ?? { mode: 'local' as const };
const selectedHostId =
activeRun?.lease?.hostId ??
(execution.mode === 'managed-host'
? execution.hostIds[0]
: undefined);
const selectedHost = hostRuntimes.find(
(host) => host.id === selectedHostId,
);
const runtimeAvailable =
execution.mode === 'local' ||
hostRuntimes.some(
(host) =>
execution.hostIds.includes(host.id) && host.status === 'online',
);
const blocked = threads.some(
(thread) =>
resolve(thread, threads).status === 'blocked' &&
@ -678,9 +529,7 @@ export function registerWorkspaceAgentRoutes(
),
);
const status =
agent.retiredAt !== undefined ||
agent.enabled === false ||
!runtimeAvailable
agent.retiredAt !== undefined || agent.enabled === false
? 'offline'
: active ||
sessionsForAgent.some((entry) => entry.hasActivePrompt)
@ -700,19 +549,8 @@ export function registerWorkspaceAgentRoutes(
...(agent.model ? { model: agent.model } : {}),
...(agent.instructions ? { instructions: agent.instructions } : {}),
maxConcurrentRuns: maxConcurrentRunsFor(agent),
execution,
enabled: agent.enabled !== false,
status,
runtime:
execution.mode === 'local'
? localRuntime
: (selectedHost ?? {
id: selectedHostId ?? 'managed-host',
kind: 'external' as const,
label: 'Managed Host',
provider: 'Unregistered',
status: 'offline' as const,
}),
// A retired agent is listed, not hidden. Its posts are still on
// the threads, and a reader who meets its name needs somewhere to
// look it up. `enabled` stays a separate answer: a retired agent
@ -737,8 +575,6 @@ export function registerWorkspaceAgentRoutes(
waiting,
};
}),
runtime: localRuntime,
runtimes: [localRuntime, ...hostRuntimes],
// What every agent may do, sent once rather than per agent because it
// is a property of the subsystem and not of an identity. Shown so the
// boundary is something a person can read before trusting an agent
@ -754,23 +590,6 @@ export function registerWorkspaceAgentRoutes(
}
});
app.post(
`${prefix}/hosts/enrollment`,
deps.mutate(),
async (req: Request, res: Response) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
try {
res.status(201).json({
...(await issueAgentHostEnrollment(runtime.workspaceCwd)),
workspaceId: runtime.workspaceId,
});
} catch (error) {
fail(res, error);
}
},
);
app.get(`${prefix}/threads`, async (req: Request, res: Response) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
@ -1275,7 +1094,6 @@ export function registerWorkspaceAgentRoutes(
model?: unknown;
instructions?: unknown;
maxConcurrentRuns?: unknown;
execution?: unknown;
};
const name = String(payload.name ?? '').trim();
if (!name) {
@ -1294,20 +1112,6 @@ export function registerWorkspaceAgentRoutes(
res.status(400).json({ error: config.error });
return;
}
const execution = readAgentExecution(payload.execution);
if (execution === 'invalid') {
res.status(400).json({ error: 'execution_invalid' });
return;
}
if (execution?.mode === 'managed-host') {
const knownHosts = new Set(
(await readAgentHosts(root)).map((host) => host.id),
);
if (execution.hostIds.some((hostId) => !knownHosts.has(hostId))) {
res.status(400).json({ error: 'agent_host_not_found' });
return;
}
}
// Narrowed on `apply` rather than on `error`: the success branch types
// `error` as an optional undefined, which never discriminated the
// union, so the guard above reads well and proves nothing to the
@ -1336,7 +1140,6 @@ export function registerWorkspaceAgentRoutes(
id: generateAgentId(),
name,
createdAt: Date.now(),
...(execution ? { execution } : {}),
});
return [...agents, created];
});
@ -1429,103 +1232,6 @@ export function registerWorkspaceAgentRoutes(
},
);
/**
* Shares: A2A grants for one agent, so a caller outside this workspace can
* message it. Each share is its own caller id, so revoking one leaves the
* others working; the secret is returned once and only its hash is kept.
*/
const SHARE_TTL_MS = 7 * 24 * 60 * 60_000;
const knownAgent = async (runtime: WorkspaceRuntime, agentId: string) =>
(await readWorkspaceAgents(runtime.workspaceCwd)).some(
(agent) => agent.id === agentId && isAgentAddressable(agent),
);
app.get(`${prefix}/agents/:id/shares`, async (req, res) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
const agentId = String(req.params['id']);
try {
const now = Date.now();
const shares = (await listA2AGrants(runtime.workspaceCwd))
.filter(
(grant) =>
grant.agentId === agentId &&
(grant.expiresAt === undefined || grant.expiresAt > now),
)
.map(({ callerId, scope, createdAt, expiresAt }) => ({
callerId,
scope,
createdAt,
...(expiresAt !== undefined ? { expiresAt } : {}),
}));
res.json({ shares });
} catch (error) {
fail(res, error);
}
});
app.post(
`${prefix}/agents/:id/shares`,
deps.mutate({ strict: true }),
async (req, res) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
const agentId = String(req.params['id']);
const scope = (req.body as { scope?: unknown } | undefined)?.scope;
if (scope !== 'analysis' && scope !== 'full') {
res.status(400).json({ error: 'share_scope_invalid' });
return;
}
try {
if (!(await knownAgent(runtime, agentId))) {
res.status(404).json({ error: 'agent_not_found' });
return;
}
const callerId = `share_${randomBytes(6).toString('hex')}`;
const expiresAt = Date.now() + SHARE_TTL_MS;
const { secret } = await issueA2AGrant(runtime.workspaceCwd, {
callerId,
agentId,
scope,
expiresAt,
});
res.status(201).json({
endpoint: `${req.protocol}://${req.get('host') ?? '127.0.0.1'}/a2a/v1`,
workspaceId: runtime.workspaceId,
callerId,
agentId,
secret,
scope,
expiresAt,
});
} catch (error) {
fail(res, error);
}
},
);
app.delete(
`${prefix}/agents/:id/shares/:callerId`,
deps.mutate({ strict: true }),
async (req, res) => {
const runtime = runtimeFor(req, res);
if (!runtime) return;
try {
const removed = await revokeA2AGrant(runtime.workspaceCwd, {
agentId: String(req.params['id']),
callerId: String(req.params['callerId']),
});
if (!removed) {
res.status(404).json({ error: 'share_not_found' });
return;
}
res.json({ revoked: true });
} catch (error) {
fail(res, error);
}
},
);
app.patch(
`${prefix}/agents/:id`,
deps.mutate({ strict: true }),
@ -1540,7 +1246,6 @@ export function registerWorkspaceAgentRoutes(
instructions?: unknown;
agentType?: unknown;
maxConcurrentRuns?: unknown;
execution?: unknown;
};
const enabled = payload.enabled;
if (enabled !== undefined && typeof enabled !== 'boolean') {
@ -1555,12 +1260,7 @@ export function registerWorkspaceAgentRoutes(
res.status(400).json({ error: config.error });
return;
}
const execution = readAgentExecution(payload.execution);
if (execution === 'invalid') {
res.status(400).json({ error: 'execution_invalid' });
return;
}
if (enabled === undefined && !config.touched && execution === undefined) {
if (enabled === undefined && !config.touched) {
res.status(400).json({ error: 'nothing_to_update' });
return;
}
@ -1568,25 +1268,6 @@ export function registerWorkspaceAgentRoutes(
const agentId = String(req.params['id']);
let missing = false;
let retired = false;
if (execution !== undefined) {
const result = await setWorkspaceAgentExecution(
runtime.workspaceCwd,
agentId,
execution,
);
if (result !== 'updated') {
const [status, error] =
result === 'not_found'
? ([404, 'agent_not_found'] as const)
: result === 'retired'
? ([409, 'agent_retired'] as const)
: result === 'host_not_found'
? ([400, 'agent_host_not_found'] as const)
: ([409, 'agent_has_live_work'] as const);
res.status(status).json({ error });
return;
}
}
if (config.touched) {
await updateWorkspaceAgents(runtime.workspaceCwd, (agents) => {
const existing = agents.find((agent) => agent.id === agentId);

View file

@ -324,8 +324,6 @@ import {
registerWorkspaceSkillsRoutes,
} from './routes/workspace-skills.js';
import { registerChannelWebhookRoutes } from './routes/channel-webhooks.js';
import { registerAgentHostTransportRoutes } from './routes/agent-hosts.js';
import { registerA2ATransportRoutes } from './routes/a2a.js';
import type {
ChannelDeliveryAccepted,
ChannelDeliveryRequest,
@ -1097,9 +1095,9 @@ export function createServeApp(
};
// Resolved once, below, from the settings read at daemon startup — not per
// request and not per session. The collaboration surface includes work no
// session owns: a recovery scan, a 5s dispatch timer and the Host transport
// routes. A per-session read cannot govern those, so the setting carries
// `requiresRestart: true` and this value is fixed for the daemon's lifetime.
// session owns: a recovery scan and a 5s dispatch timer. A per-session read
// cannot govern those, so the setting carries `requiresRestart: true` and
// this value is fixed for the daemon's lifetime.
// `agentTeamEnabled` reads per session; this one deliberately does not.
let agentCollaborationEnabled = false;
let standaloneSessionsAvailable = false;
@ -1538,9 +1536,8 @@ export function createServeApp(
// `Config.isAgentCollaborationEnabled` so a daemon and the sessions it hosts
// cannot disagree about whether the feature is on.
agentCollaborationEnabled =
!opts.agentHostWorker &&
(process.env['QWEN_CODE_ENABLE_AGENT_COLLABORATION'] === '1' ||
liveSettingsAtBoot?.experimental?.agentCollaboration === true);
process.env['QWEN_CODE_ENABLE_AGENT_COLLABORATION'] === '1' ||
liveSettingsAtBoot?.experimental?.agentCollaboration === true;
const liveConfigAtBoot = liveSettingsAtBoot
? readLiveVoiceConfiguration(liveSettingsAtBoot)
@ -2245,14 +2242,6 @@ export function createServeApp(
});
}
// Same opt-in. These routes carry Host enrollment and heartbeat; that they
// authenticate is not a substitute for the experiment gate, since an
// enrolled Host is exactly the outbound execution path the opt-in governs.
if (agentCollaborationEnabled) {
registerAgentHostTransportRoutes(app, workspaceRegistry);
registerA2ATransportRoutes(app, workspaceRegistry);
}
// Credentials are a listener-scoped set, not one token: while Local Control
// is on, the LAN listener accepts a revocable pairing token and rejects the
// runtime token, and the primary listener does the reverse. With no Local
@ -3375,13 +3364,13 @@ export function createServeApp(
workspaceRegistry,
mutate,
});
} else if (!opts.agentHostWorker) {
// Close out runs the switch left mid-flight (architecture §6). Recovery
// cannot tell "the daemon crashed" from "the operator turned this off"
// — both look like a live run whose body is gone — so if these were left
// as they are, opting back in would silently re-dispatch work nobody
// asked to resume. Marking them terminal here means recovery later finds
// a closed run, and a person decides whether the work happens again.
} else {
// Close out runs the switch left mid-flight. Recovery cannot tell "the
// daemon crashed" from "the operator turned this off" — both look like a
// live run whose body is gone — so if these were left as they are,
// opting back in would silently re-dispatch work nobody asked to resume.
// Marking them terminal here means recovery later finds a closed run, and
// a person decides whether the work happens again.
//
// A one-shot, not a scanner: no timer, no routes, nothing created in a
// workspace that never used collaboration, and untrusted workspaces are

View file

@ -362,8 +362,6 @@ export interface ServeOptions {
* integer. Default: 10000 (10 s).
*/
initializeTimeoutMs?: number;
/** A remote Host executes assignments; it does not own local scheduling. */
agentHostWorker?: boolean;
/**
* ACP session load/resume timeout in ms. Defaults to 60000 (60 s), raised
* to an explicitly set initialize timeout when that value is larger. An

View file

@ -8,12 +8,7 @@ import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import {
enrollAgentHost,
getThreadsDir,
heartbeatAgentHost,
issueAgentHostEnrollment,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
import { getThreadsDir } from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
import {
publishAgentEvent,
subscribeAgentEvents,
@ -53,33 +48,6 @@ it('announces writes to a thread file once, naming the thread', async () => {
}
});
it('announces a runtime coming online, not each of its heartbeats', async () => {
const workspace = path.join(runtimeDir, 'project');
const events: AgentLiveEvent[] = [];
const stop = await subscribeAgentEvents(workspace, (event) =>
events.push(event),
);
try {
const { token } = await issueAgentHostEnrollment(workspace);
const input = { workspaceCwd: '/remote', providers: ['Qwen Code ACP'] };
const { host, secret } = await enrollAgentHost(workspace, {
token,
name: 'laptop',
...input,
});
await heartbeatAgentHost(workspace, host.id, secret, input);
await vi.waitFor(() => expect(events).toContainEqual({ type: 'changed' }), {
timeout: 3_000,
});
events.length = 0;
await heartbeatAgentHost(workspace, host.id, secret, input);
await new Promise((resolve) => setTimeout(resolve, 400));
expect(events).toEqual([]);
} finally {
stop();
}
});
it('delivers progress to that workspace’s subscribers until they leave', async () => {
const seen: AgentLiveEvent[] = [];
const workspace = path.join(runtimeDir, 'a');

View file

@ -8,10 +8,10 @@
* Live updates for the Web Shell collaboration views.
*
* Two sources feed one stream per workspace:
* - `changed`: a write under the workspace's agent store (agents, hosts or a
* - `changed`: a write under the workspace's agent store (agents or a
* thread). Found with `fs.watch`, so every writer — routes, the dispatch
* loop, host routes, thread tools inside agent sessions — is covered without
* each of them having to remember to publish.
* loop, thread tools inside agent sessions — is covered without each of them
* having to remember to publish.
* - `progress`: a running agent's reply as it streams, published by the
* dispatch port. It goes straight to the browser and never waits on disk.
*/
@ -21,14 +21,8 @@ import { mkdir } from 'node:fs/promises';
import {
getAgentsDir,
getThreadsDir,
readAgentHosts,
} from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
/** A runtime counts as online while its last heartbeat is this recent. */
export const AGENT_HOST_ONLINE_WINDOW_MS = 15_000;
const HOST_LIVENESS_CHECK_MS = 5_000;
const HOSTS_FILE = 'hosts.json';
export interface AgentPermissionPrompt {
requestId: string;
title: string;
@ -68,8 +62,6 @@ interface Hub {
watchers: FSWatcher[];
pending: Set<string>;
timer?: ReturnType<typeof setTimeout>;
hostsKey?: string;
liveness?: ReturnType<typeof setInterval>;
}
const hubs = new Map<string, Hub>();
@ -101,35 +93,6 @@ function queueChange(hub: Hub, threadId: string): void {
}, CHANGE_DEBOUNCE_MS);
}
/**
* Heartbeats rewrite hosts.json every few seconds. Only what a person can see
* is news: a runtime joined or left, went on- or offline, or changed what it
* offers. Called on every hosts.json write and on a timer, since a runtime
* that died goes offline precisely by writing nothing.
*/
async function checkHosts(hub: Hub, workspaceCwd: string): Promise<void> {
let hosts: Awaited<ReturnType<typeof readAgentHosts>>;
try {
hosts = await readAgentHosts(workspaceCwd);
} catch {
// A busy store lock is not "every runtime left"; the next check decides.
return;
}
const now = Date.now();
const key = JSON.stringify(
hosts.map(({ lastSeenAt, ...host }) => ({
...host,
online:
lastSeenAt !== undefined &&
now - lastSeenAt <= AGENT_HOST_ONLINE_WINDOW_MS,
})),
);
const known = hub.hostsKey !== undefined;
if (key === hub.hostsKey) return;
hub.hostsKey = key;
if (known) queueChange(hub, WORKSPACE_CHANGE);
}
function watchDir(hub: Hub, dir: string, onFile: (name: string) => void): void {
try {
const watcher = watch(dir, (_event, name) => {
@ -164,20 +127,12 @@ export async function subscribeAgentEvents(
hubs.set(workspaceCwd, created);
const threadsDir = getThreadsDir(workspaceCwd);
await mkdir(threadsDir, { recursive: true }).catch(() => {});
await checkHosts(created, workspaceCwd);
watchDir(created, getAgentsDir(workspaceCwd), (name) =>
name === HOSTS_FILE
? void checkHosts(created, workspaceCwd)
: queueChange(created, WORKSPACE_CHANGE),
watchDir(created, getAgentsDir(workspaceCwd), () =>
queueChange(created, WORKSPACE_CHANGE),
);
watchDir(created, threadsDir, (name) =>
queueChange(created, name.slice(0, -5)),
);
created.liveness = setInterval(
() => void checkHosts(created, workspaceCwd),
HOST_LIVENESS_CHECK_MS,
);
created.liveness.unref();
}
const current = hub;
current.listeners.add(listener);
@ -187,7 +142,6 @@ export async function subscribeAgentEvents(
return;
hubs.delete(workspaceCwd);
if (current.timer) clearTimeout(current.timer);
clearInterval(current.liveness);
for (const watcher of current.watchers) watcher.close();
};
}

View file

@ -1,46 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash, randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import path from 'node:path';
export async function codexHostSession(directory: string, scope: string[]) {
const key = createHash('sha256').update(JSON.stringify(scope)).digest('hex');
const file = path.join(directory, `${key}.json`);
let threadId: string | undefined;
try {
const saved = JSON.parse(await fs.readFile(file, 'utf8'));
if (
saved.schemaVersion !== 1 ||
JSON.stringify(saved.scope) !== JSON.stringify(scope) ||
typeof saved.threadId !== 'string' ||
!saved.threadId.trim()
) {
throw new Error('Invalid saved Codex session; refusing to replace it.');
}
threadId = saved.threadId;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
return {
get threadId() {
return threadId;
},
async save(id: string) {
if (threadId === id) return;
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = `${file}.${randomUUID()}.tmp`;
await fs.writeFile(
temporary,
JSON.stringify({ schemaVersion: 1, scope, threadId: id }),
{ mode: 0o600, flag: 'wx' },
);
await fs.rename(temporary, file);
threadId = id;
},
};
}

View file

@ -37,22 +37,6 @@ export {
export { consumeAgentInput } from './workspace-agents/run-lifecycle.js';
export { resolveAgentPersona } from './workspace-agents/persona.js';
export { findAgentSessionBinding } from './workspace-agents/session-binding.js';
export {
issueA2AGrant,
revokeA2AGrant,
listA2AGrants,
} from './workspace-agents/a2a-grants.js';
export type {
HostRunAssignment,
HostRunResult,
} from './workspace-agents/host-lease.js';
export type {
A2ATaskView,
A2AAgentCard,
A2ACaller,
A2AFailure,
} from './workspace-agents/a2a-server.js';
export type { A2AGrant, A2AGrantScope } from './workspace-agents/types.js';
export { strandLocalRuns } from './workspace-agents/stranded-runs.js';
export type { AgentRunContext } from './workspace-agents/run-context.js';
export {
@ -69,7 +53,6 @@ export type {
} from './workspace-agents/dispatcher.js';
export type {
WorkspaceAgent,
WorkspaceAgentExecution,
Thread,
ThreadRun,
ThreadPriority,

View file

@ -1,176 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview The frozen external contract (plan P1).
*
* A2A is a rolling document; this file is the version of it this daemon speaks,
* pinned so that "A2A-compatible" means something checkable. Every constant
* here was read from the specification and from `@a2a-js/sdk@1.1.0`'s own
* type declarations, not inferred from prose — where the two disagree the SDK
* wins, because it is what a real client links against.
*
* Nothing here talks to the network. It is the vocabulary the transport layer
* will be held to when P2 builds it, kept separate so the mapping can be
* exercised before any of that exists.
*/
import type { Thread, ThreadStatus } from './types.js';
/**
* Wire version, sent and matched in the `A2A-Version` header. `Major.Minor`
* only: the specification says patch versions SHOULD NOT appear in requests,
* responses or Agent Cards.
*/
export const A2A_PROTOCOL_VERSION = '1.0';
/**
* The one binding this daemon implements.
*
* The spec defines three (`JSONRPC`, `GRPC`, `HTTP+JSON`) and mandates none.
* JSON-RPC is chosen because the daemon is already an Express app and
* `@a2a-js/sdk` ships `./server/express` for exactly this shape — gRPC would
* add `@grpc/grpc-js` and `@bufbuild/protobuf` as runtime peers for no
* capability we need. Advertised in `AgentInterface.protocolBinding`.
*/
export const A2A_TRANSPORT_BINDING = 'JSONRPC';
/** Where the unauthenticated Agent Card is published (RFC 8615). */
export const A2A_AGENT_CARD_PATH = '.well-known/agent-card.json';
/** Response content type for the HTTP+JSON binding and push payloads. */
export const A2A_CONTENT_TYPE = 'application/a2a+json';
/**
* Our protocol extension, declared in `AgentCapabilities.extensions`.
*
* A2A has nowhere in its data model for either of the two things we must carry
* across the boundary, so both ride here rather than being smuggled into a
* field that means something else:
*
* - the run frame that tells a dispatched turn which thread it acts on. The
* local channel for this is `_meta` on the ACP prompt, which is a daemon
* trust boundary and deliberately not reachable from outside; an external
* task needs its own, and `Task.metadata` under this URI is it.
* - token usage, which A2A 1.0 does not model at all.
*
* `required: false` when declared: a client that ignores the extension still
* gets correct Task and Message semantics, it just cannot see usage.
*/
export const QWEN_A2A_EXTENSION_URI =
'https://qwenlm.github.io/qwen-code/a2a/workspace-agents/v1';
/**
* A2A task states, spelled as the SDK's `TaskState` enum spells them.
*
* Kept as our own union rather than importing the SDK enum: this package must
* not take a runtime dependency on the transport layer, and the mapping below
* is the thing worth testing, not the enum's numbering.
*/
export type A2ATaskState =
| 'TASK_STATE_SUBMITTED'
| 'TASK_STATE_WORKING'
| 'TASK_STATE_INPUT_REQUIRED'
| 'TASK_STATE_AUTH_REQUIRED'
| 'TASK_STATE_COMPLETED'
| 'TASK_STATE_FAILED'
| 'TASK_STATE_CANCELED'
| 'TASK_STATE_REJECTED';
/**
* Local thread status → A2A task state.
*
* The unit mapping is deliberate and is the load-bearing decision here: an A2A
* `Task` is one local `Thread`, not one `ThreadRun`. A Task survives
* `INPUT_REQUIRED` and further input, which is exactly a thread being answered
* and worked again; a run is a single turn and has no protocol counterpart. An
* A2A `contextId` is then the thread tree — `rootThreadId` — since the spec
* calls it "the contextual collection of interactions", which is what a parent
* thread and its splits are.
*
* `in_review` maps to `INPUT_REQUIRED` rather than `WORKING`: the work is not
* progressing and it is a person who unblocks it, which is what that state
* means to a caller deciding whether to wait. The distinction between "asked a
* question" and "submitted for review" is lost across the boundary; it is
* preserved in the extension metadata for clients that care.
*/
export function toA2ATaskState(status: ThreadStatus): A2ATaskState {
switch (status) {
case 'open':
return 'TASK_STATE_SUBMITTED';
case 'in_progress':
return 'TASK_STATE_WORKING';
case 'blocked':
case 'in_review':
return 'TASK_STATE_INPUT_REQUIRED';
case 'done':
return 'TASK_STATE_COMPLETED';
case 'cancelled':
return 'TASK_STATE_CANCELED';
default: {
// Exhaustiveness: a new ThreadStatus must decide what it looks like to a
// caller, rather than silently arriving as some default.
const unreachable: never = status;
throw new Error(`Unmapped thread status: ${String(unreachable)}`);
}
}
}
/**
* The idempotency key for an inbound external submission.
*
* The protocol offers only a client-minted `messageId`, so the server scopes
* it: the same id from a different authenticated caller, or aimed at a
* different agent, is a different request. Without the scope one caller could
* collide with — or deliberately shadow — another's submission by reusing an
* id it can see or guess.
*
* Must be computed and persisted in the same write that accepts the work. A
* key written afterwards cannot answer the question it exists for, which is
* whether a retry arriving mid-acceptance is the same request; and comparing a
* stored key against a differing body is what makes "same key, different
* content" a refusal rather than a silent overwrite.
*/
export function externalRequestKey(input: {
/** Stable id of the authenticated caller, from the transport's auth. */
callerId: string;
/** The local agent the work is aimed at. */
targetAgentId: string;
/** `Message.messageId` as the caller minted it. */
messageId: string;
}): string {
const { callerId, targetAgentId, messageId } = input;
if (!callerId || !targetAgentId || !messageId) {
throw new Error(
'An external request key needs a caller, a target agent and a message id',
);
}
// Length-prefixed rather than delimiter-joined: ids are opaque strings from
// outside, and a caller able to put the separator inside one could otherwise
// produce another caller's key.
return [callerId, targetAgentId, messageId]
.map((part) => `${part.length}:${part}`)
.join('');
}
/** Everything the extension publishes about a thread, for `Task.metadata`. */
export interface QwenA2ATaskMetadata {
/** Distinguishes `blocked` from `in_review`, which A2A merges. */
localStatus: ThreadStatus;
/** Absent when this daemon has no figure; never reported as 0 for unknown. */
tokensUsed?: number;
rootThreadId: string;
}
export function toQwenA2ATaskMetadata(thread: Thread): QwenA2ATaskMetadata {
return {
localStatus: thread.status,
rootThreadId: thread.rootThreadId,
...(typeof thread.tokensUsed === 'number'
? { tokensUsed: thread.tokensUsed }
: {}),
};
}

View file

@ -1,184 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Who outside may call which agent, and to do what.
*
* Separate from the daemon's own authentication on purpose. The daemon's
* management token says "you may administer this daemon"; a grant says "this
* one external caller may ask this one agent for this one kind of work". The
* plan is explicit that the management token is never handed to an external
* collaborator, so authenticating as a caller must not be a route to it — a
* grant is the only thing an A2A request is checked against, and it names an
* agent rather than a daemon.
*
* Secrets are never stored, only their digests, and never travel in a thread,
* a prompt, a tool argument or a log line.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import {
readAgentWorkspace,
updateAgentWorkspaceCallerGrants,
} from './store.js';
import type { A2AGrant, A2AGrantScope } from './types.js';
/**
* What a grant permits.
*
* Deliberately coarse, and deliberately not "everything the agent can do".
* `analysis` is read-only work — the plan's first opened agent does read-only
* analysis of a pre-authorised repository, and code writes and open MCP access
* are explicitly not the default. A grant that cannot express "read-only"
* would make the safe case unrepresentable and the unsafe one the only option.
*/
export const A2A_GRANT_SCOPES: readonly A2AGrantScope[] = ['analysis', 'full'];
function hashSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function matchesSecret(secret: string, expectedHash: string): boolean {
const actual = Buffer.from(hashSecret(secret), 'hex');
const expected = Buffer.from(expectedHash, 'hex');
// Constant-time, and length-checked first because timingSafeEqual throws on
// a mismatch rather than returning false.
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export interface IssuedGrant {
grant: Omit<A2AGrant, 'secretHash'>;
/** Returned exactly once, at issue. Nothing stores it. */
secret: string;
}
/**
* Open one agent to one caller.
*
* A grant is per agent, not per daemon: opening agent A to a caller says
* nothing about agent B, and the plan requires that a grant in one direction
* confer nothing in the other.
*/
export async function issueA2AGrant(
projectRoot: string,
input: {
callerId: string;
agentId: string;
scope: A2AGrantScope;
expiresAt?: number;
},
now = Date.now(),
): Promise<IssuedGrant> {
const { callerId, agentId, scope } = input;
if (!callerId || !agentId) {
throw new Error('A grant needs a caller and an agent.');
}
if (!A2A_GRANT_SCOPES.includes(scope)) {
throw new Error(`Unknown grant scope "${scope}".`);
}
const secret = randomBytes(32).toString('base64url');
const grant: A2AGrant = {
callerId,
agentId,
scope,
secretHash: hashSecret(secret),
createdAt: now,
...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
};
await updateAgentWorkspaceCallerGrants(projectRoot, (grants) => [
// Re-issuing replaces rather than accumulates: two live secrets for one
// pair means revoking one leaves the caller in.
...grants.filter(
(existing) =>
existing.callerId !== callerId || existing.agentId !== agentId,
),
grant,
]);
const { secretHash: _secretHash, ...view } = grant;
return { grant: view, secret };
}
/** Withdraw a grant. Returns whether one was there to withdraw. */
export async function revokeA2AGrant(
projectRoot: string,
input: { callerId: string; agentId: string },
): Promise<boolean> {
let removed = false;
await updateAgentWorkspaceCallerGrants(projectRoot, (grants) => {
const next = grants.filter(
(grant) =>
grant.callerId !== input.callerId || grant.agentId !== input.agentId,
);
removed = next.length !== grants.length;
return next;
});
return removed;
}
export type GrantCheck =
| { ok: true; grant: Omit<A2AGrant, 'secretHash'> }
| {
ok: false;
/**
* Why it failed, for the daemon's own log. It is deliberately NOT for
* the caller: telling an unauthorised caller whether an agent exists,
* or whether its own secret was merely expired, hands it a way to
* enumerate agents and to distinguish "revoked" from "never had one".
*/
reason: 'no_grant' | 'bad_secret' | 'expired' | 'out_of_scope';
};
/**
* Check one inbound call against the grants.
*
* Every failure mode is one refusal to the caller. The distinctions above stay
* on this side of the boundary.
*/
export async function checkA2AGrant(
projectRoot: string,
input: {
callerId: string;
agentId: string;
secret: string;
/** The scope this particular call needs. */
required: A2AGrantScope;
},
now = Date.now(),
): Promise<GrantCheck> {
const workspace = await readAgentWorkspace(projectRoot);
const grant = (workspace.callerGrants ?? []).find(
(candidate) =>
candidate.callerId === input.callerId &&
candidate.agentId === input.agentId,
);
if (!grant) return { ok: false, reason: 'no_grant' };
if (!input.secret || !matchesSecret(input.secret, grant.secretHash)) {
return { ok: false, reason: 'bad_secret' };
}
if (grant.expiresAt !== undefined && grant.expiresAt <= now) {
return { ok: false, reason: 'expired' };
}
// `full` covers `analysis`; `analysis` does not cover `full`. Spelled out
// rather than ordered by array index so adding a scope cannot silently widen
// an existing one by landing in the wrong position.
const permitted =
grant.scope === 'full' ||
(grant.scope === 'analysis' && input.required === 'analysis');
if (!permitted) return { ok: false, reason: 'out_of_scope' };
const { secretHash: _secretHash, ...view } = grant;
return { ok: true, grant: view };
}
/** Grants on this workspace, without their digests. */
export async function listA2AGrants(
projectRoot: string,
): Promise<Array<Omit<A2AGrant, 'secretHash'>>> {
const workspace = await readAgentWorkspace(projectRoot);
return (workspace.callerGrants ?? []).map(
({ secretHash: _secretHash, ...view }) => view,
);
}

View file

@ -1,324 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview The five required A2A operations, over the local store.
*
* Transport-free on purpose. What a JSON-RPC layer adds is framing and HTTP
* status codes; what can actually be got wrong — who may call, what a retry
* does, which task a caller may see, what state a caller is told — is all here,
* so all of it can be exercised without a socket.
*
* Every operation takes the caller's identity and secret rather than trusting a
* caller id in the request body: an id in a payload is a claim, and the whole
* point of a grant is that the claim gets checked.
*/
import {
A2A_PROTOCOL_VERSION,
A2A_TRANSPORT_BINDING,
QWEN_A2A_EXTENSION_URI,
toA2ATaskState,
toQwenA2ATaskMetadata,
} from './a2a-contract.js';
import type { A2ATaskState, QwenA2ATaskMetadata } from './a2a-contract.js';
import { checkA2AGrant } from './a2a-grants.js';
import {
acceptExternalSubmission,
cancelExternalThreadForCaller,
getExternalThreadForCaller,
listExternalThreadsForCaller,
ExternalIntakeConflictError,
} from './external-intake.js';
import { isAgentAddressable, readWorkspaceAgents } from './store.js';
import type { A2AGrantScope, Thread, WorkspaceAgent } from './types.js';
/**
* What the transport is told to answer.
*
* A closed set, so a new failure cannot reach a caller as an unmapped
* exception carrying an internal message. `refused` is deliberately one value
* covering every authorisation failure: an unauthorised caller must not be
* able to tell "no such agent" from "wrong secret" from "revoked", or it can
* enumerate this daemon's agents.
*/
export type A2AFailure =
| { kind: 'refused' }
| { kind: 'not_found' }
| { kind: 'conflict'; existingTaskId: string }
| { kind: 'invalid'; detail: string };
export type A2AResult<T> =
| { ok: true; value: T }
| ({ ok: false } & A2AFailure);
/** The A2A `Task` this daemon publishes, in the shape the spec names. */
export interface A2ATaskView {
id: string;
contextId: string;
status: { state: A2ATaskState; timestamp: string };
metadata: Record<string, QwenA2ATaskMetadata>;
}
export interface A2ACaller {
callerId: string;
secret: string;
}
function taskView(thread: Thread): A2ATaskView {
return {
id: thread.id,
// The thread tree, not the thread: A2A calls contextId "the contextual
// collection of interactions", which is what a parent and its splits are.
contextId: thread.rootThreadId,
status: {
state: toA2ATaskState(thread.status),
timestamp: new Date().toISOString(),
},
// Namespaced by the extension URI so a client that does not implement the
// extension has no reason to read it, and two extensions cannot collide.
metadata: { [QWEN_A2A_EXTENSION_URI]: toQwenA2ATaskMetadata(thread) },
};
}
async function authorize(
projectRoot: string,
caller: A2ACaller,
agentId: string,
required: A2AGrantScope,
): Promise<{ ok: true; agent: WorkspaceAgent } | { ok: false }> {
const check = await checkA2AGrant(projectRoot, {
callerId: caller.callerId,
agentId,
secret: caller.secret,
required,
});
if (!check.ok) return { ok: false };
const agents = await readWorkspaceAgents(projectRoot);
const agent = agents.find((candidate) => candidate.id === agentId);
// A grant naming an agent that is gone, retired or disabled is not a way in.
// Checked after the secret so a caller with no valid grant learns nothing
// about which agents exist.
if (!agent || !isAgentAddressable(agent)) return { ok: false };
return { ok: true, agent };
}
/**
* `sendMessage` — submit work, or re-present a submission already made.
*
* Returns a `Task` rather than a `Message`: the work is asynchronous, and the
* spec's Message branch is for an answer available immediately, which a
* dispatched agent turn never is.
*/
export async function a2aSendMessage(
projectRoot: string,
caller: A2ACaller,
request: {
agentId: string;
messageId: string;
title: string;
body: string;
acceptanceCriteria?: string;
},
): Promise<A2AResult<A2ATaskView>> {
if (!request.messageId || !request.body) {
return {
ok: false,
kind: 'invalid',
detail: 'messageId and body required',
};
}
// Submitting work is `analysis` scope: it is the least a caller can be
// granted and still be useful, so a read-only grant can do it. What the
// agent is then allowed to *do* is the agent's own tool policy, not this.
const auth = await authorize(
projectRoot,
caller,
request.agentId,
'analysis',
);
if (!auth.ok) return { ok: false, kind: 'refused' };
try {
const accepted = await acceptExternalSubmission(projectRoot, {
callerId: caller.callerId,
targetAgentId: request.agentId,
messageId: request.messageId,
title: request.title || request.body.slice(0, 80),
body: request.body,
...(request.acceptanceCriteria
? { acceptanceCriteria: request.acceptanceCriteria }
: {}),
});
return { ok: true, value: taskView(accepted.thread) };
} catch (error) {
if (error instanceof ExternalIntakeConflictError) {
return {
ok: false,
kind: 'conflict',
existingTaskId: error.existingThreadId,
};
}
throw error;
}
}
/**
* `getTask` — poll one task.
*
* `not_found` covers missing tasks, ownership and credential failures so an
* unauthorised caller cannot distinguish them by their error codes.
*/
export async function a2aGetTask(
projectRoot: string,
caller: A2ACaller,
taskId: string,
): Promise<A2AResult<A2ATaskView>> {
const thread = await getExternalThreadForCaller(
projectRoot,
caller.callerId,
taskId,
);
if (!thread || !thread.externalIntake)
return { ok: false, kind: 'not_found' };
const auth = await authorize(
projectRoot,
caller,
thread.externalIntake.targetAgentId,
'analysis',
);
// A revoked caller loses its own history too. Otherwise revocation would
// stop new work while leaving the old readable indefinitely.
if (!auth.ok) return { ok: false, kind: 'not_found' };
return { ok: true, value: taskView(thread) };
}
/** `listTasks` — this caller's tasks and no one else's. */
export async function a2aListTasks(
projectRoot: string,
caller: A2ACaller,
agentId: string,
): Promise<A2AResult<A2ATaskView[]>> {
const auth = await authorize(projectRoot, caller, agentId, 'analysis');
if (!auth.ok) return { ok: false, kind: 'refused' };
const threads = await listExternalThreadsForCaller(
projectRoot,
caller.callerId,
);
return {
ok: true,
// Scoped twice: to the caller by the store, and to the agent the grant
// names. One caller holding two grants must not see across them.
value: threads
.filter((thread) => thread.externalIntake?.targetAgentId === agentId)
.map(taskView),
};
}
/**
* `cancelTask` — withdraw work.
*
* The returned task says no further work will start. It does not claim the
* body has stopped: `runsStillLive` is reported alongside so the transport can
* keep the receipt and the actual stop separate, which the plan requires.
*/
export async function a2aCancelTask(
projectRoot: string,
caller: A2ACaller,
taskId: string,
): Promise<A2AResult<{ task: A2ATaskView; runsStillLive: number }>> {
const existing = await getExternalThreadForCaller(
projectRoot,
caller.callerId,
taskId,
);
if (!existing?.externalIntake) return { ok: false, kind: 'not_found' };
const auth = await authorize(
projectRoot,
caller,
existing.externalIntake.targetAgentId,
'analysis',
);
if (!auth.ok) return { ok: false, kind: 'not_found' };
const cancelled = await cancelExternalThreadForCaller(
projectRoot,
caller.callerId,
taskId,
);
if (!cancelled) return { ok: false, kind: 'not_found' };
return {
ok: true,
value: {
task: taskView(cancelled.thread),
runsStillLive: cancelled.runsStillLive,
},
};
}
export interface A2AAgentCard {
protocolVersion: string;
name: string;
description: string;
interfaces: Array<{ url: string; protocolBinding: string }>;
capabilities: {
streaming: boolean;
pushNotifications: boolean;
extendedAgentCard: boolean;
extensions: Array<{ uri: string; description: string; required: boolean }>;
};
skills: Array<{ id: string; name: string; description: string }>;
}
/**
* `getAuthenticatedExtendedAgentCard` — what this daemon offers one caller.
*
* Built per caller rather than published wholesale: the card names the agents
* it can address, and that list is exactly its grants. A caller with one grant
* does not learn from the card that other agents exist. The unauthenticated
* card at `.well-known/agent-card.json` is a different, deliberately emptier
* document — it exists for discovery, not for enumeration.
*/
export async function a2aAgentCardForCaller(
projectRoot: string,
caller: A2ACaller,
agentIds: readonly string[],
baseUrl: string,
): Promise<A2AAgentCard> {
const skills: A2AAgentCard['skills'] = [];
for (const agentId of agentIds) {
const auth = await authorize(projectRoot, caller, agentId, 'analysis');
if (!auth.ok) continue;
skills.push({
id: auth.agent.id,
name: auth.agent.name,
description: auth.agent.description ?? '',
});
}
return {
protocolVersion: A2A_PROTOCOL_VERSION,
name: 'Qwen Code workspace agents',
description: 'Workspace agents collaborating on shared task threads',
interfaces: [
{ url: `${baseUrl}/a2a/v1`, protocolBinding: A2A_TRANSPORT_BINDING },
],
capabilities: {
// Not implemented, so not advertised. The spec gates the optional
// operations on these flags, and advertising one we do not serve turns a
// client's correct behaviour into a failed call.
streaming: false,
pushNotifications: false,
extendedAgentCard: true,
extensions: [
{
uri: QWEN_A2A_EXTENSION_URI,
description:
'Carries the local thread status A2A merges, and token usage, which A2A 1.0 does not model',
required: false,
},
],
},
skills,
};
}

View file

@ -22,7 +22,6 @@ import { assembleAgentPrompt } from './prompt.js';
import {
generateRunId,
isAgentAddressable,
isAgentLocal,
listThreads,
maxConcurrentRunsFor,
readWorkspaceAgents,
@ -272,7 +271,7 @@ async function rebookUndeliveredTriggers(
);
}
export async function rebookUndeliveredTriggersInTransaction(
async function rebookUndeliveredTriggersInTransaction(
transaction: AgentStoreTransaction,
threadId: string,
runId: string,
@ -394,9 +393,7 @@ export function selectCandidates(
const agent = agents.find((candidate) => candidate.id === run.agentId);
// A retired or disabled agent keeps its history and its name but takes
// no new work; the roster entry survives so its old posts still read.
if (!agent || !isAgentAddressable(agent) || !isAgentLocal(agent)) {
continue;
}
if (!agent || !isAgentAddressable(agent)) continue;
queued.push({ agent, thread, run });
}
}
@ -787,7 +784,6 @@ export async function dispatchOnce(
const now = options.now ?? Date.now();
const workspace = await readAgentWorkspace(projectRoot);
const agents = await readWorkspaceAgents(projectRoot);
const localAgents = agents.filter(isAgentLocal);
let { threads } = await listThreads(projectRoot);
const records: DispatchRecord[] = [];
@ -795,7 +791,7 @@ export async function dispatchOnce(
...(await reconcileInterruptedRuns(
projectRoot,
port,
localAgents,
agents,
threads,
now,
)),
@ -806,14 +802,14 @@ export async function dispatchOnce(
projectRoot,
port,
workspace.workspaceId,
localAgents,
agents,
threads,
now,
)),
);
({ threads } = await listThreads(projectRoot));
for (const candidate of selectCandidates(localAgents, threads)) {
for (const candidate of selectCandidates(agents, threads)) {
const { agent, thread, run } = candidate;
const base = { agentId: agent.id, threadId: thread.id, runId: run.id };
const sessionId = priorSessionId(thread, run);
@ -856,7 +852,7 @@ export async function dispatchOnce(
agent,
run: claimed.run,
thread: claimed.thread,
roster: localAgents,
roster: agents,
});
// Reserve the session id before the port creates it. Session creation

View file

@ -1,256 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Accepting work from an authenticated external caller.
*
* The protocol half of this is frozen in `a2a-contract.ts`; this is the store
* half, and it exists separately because the two things it has to get right are
* both about persistence rather than about A2A:
*
* - a retry must not produce a second piece of work, and
* - one caller must not be able to read or steer another's.
*
* Neither needs a network to be wrong, so neither waits for one to be checked.
*/
import { createHash } from 'node:crypto';
import { externalRequestKey } from './a2a-contract.js';
import {
createThreadInTransaction,
withAgentStoreTransaction,
} from './store.js';
import { isThreadTerminal } from './types.js';
import { postMessageInTransaction } from './thread-actions.js';
import { HUMAN_AUTHOR_ID } from './types.js';
import type { ExternalIntake, Thread } from './types.js';
/**
* Raised when a caller reuses a key for different content.
*
* Its own class because the transport has to answer this one differently from
* every other failure: it is not "your request failed", it is "you already used
* this id for something else", and a caller that cannot tell those apart will
* retry forever.
*/
export class ExternalIntakeConflictError extends Error {
constructor(
readonly key: string,
readonly existingThreadId: string,
) {
super(
`Request key already accepted for different content (thread ${existingThreadId})`,
);
this.name = 'ExternalIntakeConflictError';
}
}
export interface ExternalSubmission {
/** Stable id of the authenticated caller, from the transport's auth. */
callerId: string;
/** The local agent this work is aimed at. */
targetAgentId: string;
/** `Message.messageId` as the caller minted it. */
messageId: string;
title: string;
body: string;
acceptanceCriteria?: string;
}
export interface ExternalAcceptance {
/** `accepted` on first sight; `duplicate` when the same submission returns. */
outcome: 'accepted' | 'duplicate';
thread: Thread;
}
/**
* The digest that decides whether a repeated key is the same request.
*
* Length-prefixed for the same reason the key itself is: these are strings from
* outside, and joining them with a separator would let a caller move content
* across field boundaries without changing the digest.
*/
function contentHashOf(submission: ExternalSubmission): string {
const parts = [
submission.title,
submission.body,
submission.acceptanceCriteria ?? '',
];
const hash = createHash('sha256');
for (const part of parts) hash.update(`${part.length}:${part}`);
return hash.digest('hex');
}
function findByKey(
threads: readonly Thread[],
key: string,
): Thread | undefined {
return threads.find((thread) => thread.externalIntake?.key === key);
}
/**
* Accept one external submission, or recognise it as a retry.
*
* The lookup, the intake record and the thread are one transaction. Split
* across two, a retry arriving between them would be looked up, not found, and
* accepted a second time — which is exactly the failure the key exists to
* prevent, so writing it afterwards would be writing it too late.
*/
export async function acceptExternalSubmission(
projectRoot: string,
submission: ExternalSubmission,
now = Date.now(),
): Promise<ExternalAcceptance> {
const key = externalRequestKey({
callerId: submission.callerId,
targetAgentId: submission.targetAgentId,
messageId: submission.messageId,
});
const contentHash = contentHashOf(submission);
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const { threads } = await transaction.listThreads();
const existing = findByKey(threads, key);
if (existing) {
if (existing.externalIntake?.contentHash !== contentHash) {
throw new ExternalIntakeConflictError(key, existing.id);
}
// Deliberately does not post the message again. A retry means the caller
// did not hear the answer, not that it wants the work done twice.
return { outcome: 'duplicate' as const, thread: existing };
}
const intake: ExternalIntake = {
key,
callerId: submission.callerId,
targetAgentId: submission.targetAgentId,
messageId: submission.messageId,
contentHash,
receivedAt: now,
};
const created = await createThreadInTransaction(transaction, {
title: submission.title,
body: submission.body,
createdBy: HUMAN_AUTHOR_ID,
assigneeAgentId: submission.targetAgentId,
externalIntake: intake,
...(submission.acceptanceCriteria
? { acceptanceCriteria: submission.acceptanceCriteria }
: {}),
});
// The message is what books a run, so it lands in the same write as the
// intake record: a thread accepted but never dispatched would report
// `SUBMITTED` forever with nothing behind it.
const posted = await postMessageInTransaction(transaction, created.id, {
from: HUMAN_AUTHOR_ID,
text: submission.body,
});
return { outcome: 'accepted' as const, thread: posted.thread };
});
}
/**
* The threads one caller may see.
*
* Scoped by `callerId`, not merely filtered for convenience: B serves several
* authorized clients over one queue, and a second client must not be able to
* read — or cancel, or add to — the first client's work. Locally raised threads
* have no intake record and so belong to no external caller; they are not
* listed to any of them.
*/
export async function listExternalThreadsForCaller(
projectRoot: string,
callerId: string,
): Promise<Thread[]> {
if (!callerId) return [];
const { threads } = await withAgentStoreTransaction(projectRoot, (t) =>
t.listThreads(),
);
return threads.filter(
(thread) => thread.externalIntake?.callerId === callerId,
);
}
/**
* Withdraw one of this caller's tasks.
*
* Two writes are deliberately NOT collapsed into one here: this marks the
* thread terminal, which stops anything further being dispatched for it, but
* it does not claim the body has stopped. A run already executing keeps
* running until the dispatcher's own cancellation path reaches it, and the
* plan is explicit that a cancellation receipt and an actual stop are
* separately reported — a caller told "cancelled" while the work continues is
* the failure worth avoiding, so the receipt says what is true: no further
* work will be started.
*
* Returns `undefined` for a thread that is not this caller's, on the same
* reasoning as {@link getExternalThreadForCaller}: distinguishing "no such
* task" from "not yours" leaks another client's task ids.
*/
export async function cancelExternalThreadForCaller(
projectRoot: string,
callerId: string,
threadId: string,
): Promise<{ thread: Thread; runsStillLive: number } | undefined> {
if (!callerId || !threadId) return undefined;
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const thread = await transaction.readThread(threadId);
if (!thread || thread.externalIntake?.callerId !== callerId) {
return undefined;
}
const runsStillLive = thread.runs.filter(
(run) =>
run.status === 'running' ||
run.status === 'finishing' ||
run.status === 'cancelling',
).length;
// Already terminal: report it rather than overwriting a `done` with a
// `cancelled`, which would rewrite how the work actually ended.
if (isThreadTerminal(thread.status)) {
return { thread, runsStillLive };
}
// Retires the runs the same way the "mark done" path does: a queued run
// that no selection will ever pick is still shown as pending work on a
// task its caller withdrew, and a live one has to be asked to stop rather
// than quietly relabelled. `cancelling` is a request, not a report — which
// is why `runsStillLive` is returned separately, so the receipt can say
// "no further work will start" without claiming the body has stopped.
const now = Date.now();
const next = await transaction.writeThread({
...thread,
status: 'cancelled' as const,
runs: thread.runs.map((run) =>
run.status === 'queued'
? { ...run, status: 'cancelled' as const, endedAt: now }
: run.status === 'running' || run.status === 'finishing'
? { ...run, status: 'cancelling' as const }
: run,
),
});
return { thread: next, runsStillLive };
});
}
/**
* One thread, if it is this caller's.
*
* Returns `undefined` for "no such thread" and for "not yours" alike. The
* transport must not distinguish them either: a caller able to tell a thread
* exists but belongs to someone else can enumerate another client's work.
*/
export async function getExternalThreadForCaller(
projectRoot: string,
callerId: string,
threadId: string,
): Promise<Thread | undefined> {
if (!callerId || !threadId) return undefined;
const thread = await withAgentStoreTransaction(projectRoot, (t) =>
t.readThread(threadId),
);
if (!thread) return undefined;
return thread.externalIntake?.callerId === callerId ? thread : undefined;
}

View file

@ -1,512 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Leases for outbound Host execution (plan P4).
*
* A managed Host reaches out; nothing reaches in. That means the daemon cannot
* tell a Host that is thinking from one whose network died, so work it holds
* has to become available again on its own. The danger in doing that is the
* obvious one: the first Host comes back and writes its result over work a
* second Host has since done.
*
* A lease is what makes reclaiming safe. Re-acquiring mints a new `leaseId`,
* and every write is checked against both the id and the run attempt, so a
* worker holding a stale lease is refused rather than believed. That check is
* pure state, so it is settled here rather than waiting for two machines.
*
* This module owns the atomic pickup and result commit. HTTP only authenticates
* the Host and carries these decisions across the network.
*/
import { randomBytes } from 'node:crypto';
import { assembleAgentPrompt } from './prompt.js';
import { rebookUndeliveredTriggersInTransaction } from './dispatcher.js';
import {
isAgentAddressable,
isAgentExecutableByHost,
maxConcurrentRunsFor,
withAgentStoreTransaction,
type AgentStoreTransaction,
} from './store.js';
import {
closeRunInTransaction,
finishRunInTransaction,
type RunCloseRequest,
} from './run-lifecycle.js';
import {
isThreadTerminal,
threadPriorityRank,
type RunLease,
type Thread,
type ThreadRun,
type WorkspaceAgent,
} from './types.js';
/** Deliberately short. A dead Host should not hold work for long. */
export const DEFAULT_RUN_LEASE_MS = 60_000;
export type LeaseRefusal =
| 'no_such_run'
| 'not_leasable'
| 'stale_lease'
| 'attempt_moved_on';
export type LeaseResult<T> =
| { ok: true; value: T }
| { ok: false; reason: LeaseRefusal };
export interface HostRunAssignment {
workspaceId: string;
agent: WorkspaceAgent;
threadId: string;
rootThreadId: string;
runId: string;
attempt: number;
prompt: string;
contextThroughSequence: number;
lease: RunLease;
}
export interface HostRunResult {
threadId: string;
runId: string;
hostId: string;
leaseId: string;
attempt: number;
status: 'completed' | 'failed' | 'cancelled';
close?: RunCloseRequest;
error?: string;
}
function liveLease(
run: { lease?: RunLease },
now: number,
): RunLease | undefined {
const lease = run.lease;
if (!lease) return undefined;
return lease.expiresAt > now ? lease : undefined;
}
function withRun(
thread: Thread,
runId: string,
update: (run: Thread['runs'][number]) => Thread['runs'][number],
): Thread {
return {
...thread,
runs: thread.runs.map((run) => (run.id === runId ? update(run) : run)),
};
}
/**
* Extend a hold the caller still legitimately has.
*
* A heartbeat, not a claim: it refuses an expired lease rather than reviving
* it. Reviving would let a Host that was unreachable for longer than the window
* carry on as though nothing happened, which is exactly the case the window
* exists to notice.
*/
export async function renewRunLease(
projectRoot: string,
input: {
threadId: string;
runId: string;
leaseId: string;
hostId?: string;
attempt?: number;
ttlMs?: number;
},
now = Date.now(),
): Promise<LeaseResult<RunLease>> {
const ttl = input.ttlMs ?? DEFAULT_RUN_LEASE_MS;
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const checked = await checkRunLeaseInTransaction(transaction, input, now);
if (!checked.ok) return checked;
if (input.hostId !== undefined && checked.value.hostId !== input.hostId) {
return { ok: false, reason: 'stale_lease' as const };
}
const thread = await transaction.readThread(input.threadId);
const run = thread?.runs.find((candidate) => candidate.id === input.runId);
if (!thread || !run) return { ok: false, reason: 'no_such_run' as const };
if (isThreadTerminal(thread.status) || run.status !== 'running') {
return { ok: false, reason: 'not_leasable' as const };
}
const lease: RunLease = { ...checked.value, expiresAt: now + ttl };
await transaction.writeThread(
withRun(thread, input.runId, (target) => ({ ...target, lease })),
);
return { ok: true as const, value: lease };
});
}
export async function reportHostRunProgress(
projectRoot: string,
input: {
threadId: string;
runId: string;
hostId: string;
leaseId: string;
attempt: number;
sequence: number;
stage: string;
detail: string;
outputText?: string;
thoughtText?: string;
},
) {
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const now = Date.now();
const checked = await checkRunLeaseInTransaction(transaction, input, now);
if (!checked.ok) return checked;
if (checked.value.hostId !== input.hostId) {
return { ok: false, reason: 'stale_lease' as const };
}
const thread = await transaction.readThread(input.threadId);
const run = thread?.runs.find((candidate) => candidate.id === input.runId);
if (!thread || !run || run.status !== 'running') {
return { ok: false, reason: 'not_leasable' as const };
}
const previous =
run.progress?.attempt === input.attempt ? run.progress : undefined;
if (previous && previous.sequence > input.sequence) return { ok: true };
run.progress = {
attempt: input.attempt,
sequence: input.sequence,
receivedAt: now,
activityAt:
previous?.sequence === input.sequence ? previous.activityAt : now,
stage:
previous?.sequence === input.sequence ? previous.stage : input.stage,
detail:
previous?.sequence === input.sequence ? previous.detail : input.detail,
outputText:
previous?.sequence === input.sequence
? previous.outputText
: (input.outputText ?? previous?.outputText),
thoughtText:
previous?.sequence === input.sequence
? previous.thoughtText
: (input.thoughtText ?? previous?.thoughtText),
};
await transaction.writeThread(thread);
return { ok: true };
});
}
/**
* Check whether a Host may write a result for this run, right now.
*
* Both halves matter and they fail differently. The `leaseId` catches a worker
* whose hold was taken over; the attempt catches the subtler case where the run
* was requeued and started again — possibly by the very same Host — so an id
* from the previous attempt would otherwise still look current.
*/
export async function checkRunLeaseInTransaction(
transaction: AgentStoreTransaction,
input: {
threadId: string;
runId: string;
leaseId: string;
attempt?: number;
},
now = Date.now(),
): Promise<LeaseResult<RunLease>> {
const thread = await transaction.readThread(input.threadId);
const run = thread?.runs.find((candidate) => candidate.id === input.runId);
if (!run) return { ok: false, reason: 'no_such_run' };
if (input.attempt !== undefined && input.attempt !== run.attempts) {
return { ok: false, reason: 'attempt_moved_on' };
}
const lease = run.lease;
if (!lease || lease.leaseId !== input.leaseId) {
return { ok: false, reason: 'stale_lease' };
}
if (lease.attempt !== run.attempts) {
return { ok: false, reason: 'attempt_moved_on' };
}
if (lease.expiresAt <= now) return { ok: false, reason: 'stale_lease' };
return { ok: true, value: lease };
}
function assignmentFor(
transaction: AgentStoreTransaction,
agent: WorkspaceAgent,
thread: Thread,
run: ThreadRun,
roster: readonly WorkspaceAgent[],
): Omit<HostRunAssignment, 'lease'> {
const prompt = assembleAgentPrompt({
workspaceId: transaction.workspaceId,
agent,
thread,
run,
roster,
});
return {
workspaceId: transaction.workspaceId,
agent,
threadId: thread.id,
rootThreadId: thread.rootThreadId,
runId: run.id,
attempt: run.attempts,
prompt: prompt.text,
contextThroughSequence: prompt.contextThroughSequence,
};
}
/** Atomically claims the oldest run this Host is allowed to execute. */
export async function pickupRunForHost(
projectRoot: string,
hostId: string,
now = Date.now(),
): Promise<HostRunAssignment | undefined> {
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const agents = await transaction.readAgents();
const roster = agents.filter(isAgentAddressable);
const placed = new Map(
agents
.filter((agent) => isAgentExecutableByHost(agent, hostId))
.map((agent) => [agent.id, agent]),
);
if (placed.size === 0) return undefined;
const { threads, unreadable } = await transaction.listThreads();
if (unreadable.length > 0) {
throw new Error(
`Cannot pick up Agent work while thread records are unreadable: ${unreadable.join(', ')}.`,
);
}
const held = threads
.flatMap((thread) =>
thread.runs.map((run) => ({
thread,
run,
agent: placed.get(run.agentId),
})),
)
.find(
({ thread, run, agent }) =>
agent !== undefined &&
// Same rule the dispatcher applies. A thread that went terminal
// while a Host held it must not have that hold extended: the work
// is over, and renewing would keep a worker busy on it.
!isThreadTerminal(thread.status) &&
run.status === 'running' &&
run.lease?.hostId === hostId &&
run.lease.attempt === run.attempts &&
run.lease.expiresAt > now,
);
if (held?.agent && held.run.lease) {
const lease = {
...held.run.lease,
expiresAt: now + DEFAULT_RUN_LEASE_MS,
};
const stored = await transaction.writeThread(
withRun(held.thread, held.run.id, (run) => ({ ...run, lease })),
);
const run = stored.runs.find(
(candidate) => candidate.id === held.run.id,
)!;
return {
...assignmentFor(transaction, held.agent, stored, run, roster),
lease,
};
}
const candidates = threads
.flatMap((thread) =>
thread.runs.map((run) => ({
thread,
run,
agent: placed.get(run.agentId),
})),
)
.filter(
(
candidate,
): candidate is {
thread: Thread;
run: ThreadRun;
agent: WorkspaceAgent;
} =>
candidate.agent !== undefined &&
// The dispatcher's `selectCandidates` refuses terminal threads; this
// is the second selection path and has to agree with it. Without
// this a Host is handed work on a thread whose caller cancelled it
// or whose owner marked it done — observed, not theorised: the audit
// harness picked up such a run before this line existed.
!isThreadTerminal(candidate.thread.status) &&
((candidate.run.status === 'queued' &&
isAgentAddressable(candidate.agent)) ||
(candidate.run.status === 'running' &&
!liveLease(candidate.run, now))),
)
.sort(
(a, b) =>
threadPriorityRank(a.thread.priority) -
threadPriorityRank(b.thread.priority) ||
a.run.queueSequence - b.run.queueSequence,
);
const candidate = candidates.find(({ agent, run }) => {
const occupied = threads.reduce(
(count, thread) =>
count +
thread.runs.filter(
(other) =>
other.id !== run.id &&
other.agentId === agent.id &&
(other.status === 'running' ||
other.status === 'finishing' ||
other.status === 'cancelling') &&
liveLease(other, now) !== undefined,
).length,
0,
);
return occupied < maxConcurrentRunsFor(agent);
});
if (!candidate) return undefined;
const run: ThreadRun =
candidate.run.status === 'queued'
? {
...candidate.run,
status: 'running',
attempts: candidate.run.attempts + 1,
startedAt: now,
}
: candidate.run;
const lease: RunLease = {
hostId,
leaseId: randomBytes(16).toString('hex'),
attempt: run.attempts,
acquiredAt: now,
expiresAt: now + DEFAULT_RUN_LEASE_MS,
};
const prompt = assembleAgentPrompt({
workspaceId: transaction.workspaceId,
agent: candidate.agent,
thread: candidate.thread,
run,
roster,
});
const committed =
candidate.thread.deliveryByAgent[run.agentId]?.committedThroughSequence ??
0;
const delivered = candidate.thread.messages
.filter(
(message) =>
message.sequence > committed &&
message.sequence <= prompt.contextThroughSequence,
)
.map((message) => message.id);
const nextRun = {
...run,
lease,
acceptedMessageIds: Array.from(
new Set([...run.acceptedMessageIds, ...delivered]),
),
contextThroughSequence: prompt.contextThroughSequence,
};
const stored = await transaction.writeThread(
withRun(candidate.thread, run.id, () => nextRun),
);
return {
...assignmentFor(transaction, candidate.agent, stored, nextRun, roster),
lease,
};
});
}
/** Applies a Host result only while that exact attempt still owns the lease. */
export async function applyHostRunResult(
projectRoot: string,
input: HostRunResult,
now = Date.now(),
): Promise<LeaseResult<{ thread: Thread; alreadyApplied: boolean }>> {
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const current = await transaction.readThread(input.threadId);
const run = current?.runs.find((candidate) => candidate.id === input.runId);
if (!current || !run) return { ok: false, reason: 'no_such_run' as const };
if (
(run.status === 'completed' ||
run.status === 'failed' ||
run.status === 'cancelled') &&
run.attempts === input.attempt &&
run.lease?.attempt === input.attempt &&
run.lease.hostId === input.hostId &&
run.lease.leaseId === input.leaseId
) {
return {
ok: true as const,
value: { thread: current, alreadyApplied: true },
};
}
const checked = await checkRunLeaseInTransaction(transaction, input, now);
if (!checked.ok) return checked;
if (checked.value.hostId !== input.hostId) {
return { ok: false, reason: 'stale_lease' as const };
}
await transaction.writeThread(
withRun(current, run.id, (target) => ({
...target,
consumedMessageIds: Array.from(
new Set([...target.consumedMessageIds, ...target.acceptedMessageIds]),
),
})),
);
if (
run.status === 'finishing' &&
(input.status !== 'completed' ||
(input.close !== undefined && run.closeKind !== input.close.kind))
) {
return { ok: false, reason: 'not_leasable' as const };
}
if (
run.status === 'running' &&
input.status === 'completed' &&
input.close
) {
await rebookUndeliveredTriggersInTransaction(
transaction,
current.id,
run.id,
run.attempts,
now,
);
await closeRunInTransaction(transaction, {
context: {
workspaceId: transaction.workspaceId,
agentId: run.agentId,
threadId: current.id,
rootThreadId: current.rootThreadId,
runId: run.id,
attempt: run.attempts,
},
request: input.close,
now,
});
}
const thread = await finishRunInTransaction(transaction, {
threadId: input.threadId,
runId: input.runId,
outcome: {
status: input.status,
attempt: input.attempt,
...(input.error ? { error: input.error } : {}),
...(input.status === 'failed' ? { failureStage: 'execution' } : {}),
},
now,
});
return {
ok: true as const,
value: { thread, alreadyApplied: false },
};
});
}

View file

@ -113,14 +113,12 @@ export async function resolveAgentPersona(
// another, wearing the first one's instructions.
//
// Refused rather than ignored, on the same reasoning as the rendered
// prompt below. A workspace agent that should run elsewhere says so with
// `execution: { mode: 'managed-host' }` on its own record, which the
// dispatcher honours; an executor block on a borrowed definition is a
// prompt below: an executor block on a borrowed definition is a
// misconfiguration, and a silent one is the expensive kind.
if (loaded.executor !== undefined) {
return {
status: 'unavailable',
error: `Agent definition "${agent.agentType}" declares an external executor, which a workspace Agent cannot use. Set execution.mode to "managed-host" on the Agent instead, or use a definition without an executor block.`,
error: `Agent definition "${agent.agentType}" declares an external executor, which a workspace Agent cannot use. Use a definition without an executor block.`,
};
}
const runtime = await manager.convertToRuntimeConfig(loaded, config);

View file

@ -38,7 +38,7 @@ import { postMessageInTransaction } from './thread-actions.js';
import { mentionToken } from './mentions.js';
/** How an agent says its run is done. `unclosed` is recorded, never chosen. */
export type RunCloseRequest =
type RunCloseRequest =
| { kind: 'waiting' }
| { kind: 'blocked'; question: string }
| { kind: 'review'; summary: string };
@ -251,7 +251,7 @@ function enqueue(
* written: a close that names a run the agent does not own, or a run that is
* not executing, is a wiring or replay error, not a workflow event.
*/
export async function closeRunInTransaction(
async function closeRunInTransaction(
transaction: AgentStoreTransaction,
input: CloseRunInput,
): Promise<CloseRunResult> {

View file

@ -5,12 +5,7 @@
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import {
createHash,
randomBytes,
randomUUID,
timingSafeEqual,
} from 'node:crypto';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Mutex } from 'async-mutex';
@ -25,16 +20,10 @@ import {
HUMAN_AUTHOR_ID,
MAX_THREAD_MESSAGES,
MAX_THREAD_RUNS,
AGENT_HOSTS_SCHEMA_VERSION,
AGENTS_SCHEMA_VERSION,
type AgentHost,
type AgentHostView,
type AgentHostsFile,
type WorkspaceAgent,
type WorkspaceAgentsFile,
type AgentWorkspaceState,
type A2AGrant,
type ExternalIntake,
type MessageOutcome,
type RunCloseKind,
type RunUsageRound,
@ -52,9 +41,7 @@ import {
const AGENTS_DIRNAME = 'agent-host';
const WORKSPACE_FILENAME = 'workspace.json';
const AGENTS_FILENAME = 'agents.json';
const HOSTS_FILENAME = 'hosts.json';
const THREADS_DIRNAME = 'threads';
const HOST_ENROLLMENT_TTL_MS = 15 * 60 * 1_000;
const LOCK_OPTIONS: lockfile.LockOptions = {
realpath: false,
@ -99,10 +86,6 @@ export function getAgentsFilePath(projectRoot: string): string {
return path.join(getAgentsDir(projectRoot), AGENTS_FILENAME);
}
export function getAgentHostsFilePath(projectRoot: string): string {
return path.join(getAgentsDir(projectRoot), HOSTS_FILENAME);
}
export function getThreadsDir(projectRoot: string): string {
return path.join(getAgentsDir(projectRoot), THREADS_DIRNAME);
}
@ -113,10 +96,6 @@ export function generateAgentId(): string {
return `ag_${randomUUID()}`;
}
function generateAgentHostId(): string {
return `host_${randomUUID()}`;
}
export function generateThreadId(): string {
return `th_${randomUUID()}`;
}
@ -178,19 +157,6 @@ export function isValidAgentName(value: unknown): value is string {
function isValidAgent(value: unknown): value is WorkspaceAgent {
if (!isRecord(value)) return false;
const execution = value['execution'];
const validExecution =
execution === undefined ||
(isRecord(execution) &&
(execution['mode'] === 'local' ||
(execution['mode'] === 'managed-host' &&
Array.isArray(execution['hostIds']) &&
execution['hostIds'].length > 0 &&
execution['hostIds'].every(isValidId) &&
new Set(execution['hostIds']).size === execution['hostIds'].length &&
(execution['provider'] === undefined ||
execution['provider'] === 'qwen' ||
execution['provider'] === 'codex'))));
return (
isValidId(value['id']) &&
isValidAgentName(value['name']) &&
@ -212,8 +178,7 @@ function isValidAgent(value: unknown): value is WorkspaceAgent {
(value['retiredAt'] === undefined ||
isFiniteTimestamp(value['retiredAt'])) &&
(value['maxConcurrentRuns'] === undefined ||
isPositiveInteger(value['maxConcurrentRuns'])) &&
validExecution
isPositiveInteger(value['maxConcurrentRuns']))
);
}
@ -330,10 +295,6 @@ function isValidRun(value: unknown): value is ThreadRun {
isOptionalNonNegativeInteger(value['contextThroughSequence']) &&
(value['closeKind'] === undefined ||
CLOSE_KINDS.has(value['closeKind'] as RunCloseKind)) &&
// Malformed fails the record rather than being dropped: a dropped lease
// reads as "nobody holds this run", which is the one answer that lets two
// Hosts execute the same work.
(value['lease'] === undefined || isValidRunLease(value['lease'])) &&
isOptionalNonNegativeInteger(value['closeAcknowledgedAtSequence']) &&
(value['finalMessageId'] === undefined ||
isValidId(value['finalMessageId'])) &&
@ -378,18 +339,6 @@ function isValidEvent(value: unknown): value is ThreadEvent {
);
}
function isValidExternalIntake(value: unknown): boolean {
return (
isRecord(value) &&
isNonEmptyString(value['key']) &&
isNonEmptyString(value['callerId']) &&
isValidId(value['targetAgentId']) &&
isNonEmptyString(value['messageId']) &&
isNonEmptyString(value['contentHash']) &&
isFiniteTimestamp(value['receivedAt'])
);
}
function isValidThread(value: unknown): value is Thread {
if (!isRecord(value)) return false;
if (
@ -428,13 +377,7 @@ function isValidThread(value: unknown): value is Thread {
(value['acceptanceCriteria'] !== undefined &&
typeof value['acceptanceCriteria'] !== 'string') ||
(value['priority'] !== undefined &&
!THREAD_PRIORITIES.has(value['priority'] as ThreadPriority)) ||
// Present-but-malformed is rejected rather than ignored: this record is
// what makes a retry idempotent and what scopes reads to their caller, so
// a thread carrying an unreadable one must not be served at all — dropping
// the field would silently hand it to whoever asked next.
(value['externalIntake'] !== undefined &&
!isValidExternalIntake(value['externalIntake']))
!THREAD_PRIORITIES.has(value['priority'] as ThreadPriority))
) {
return false;
}
@ -467,29 +410,6 @@ function isValidThread(value: unknown): value is Thread {
return value['nextMessageSequence'] > previousSequence;
}
function isValidRunLease(value: unknown): boolean {
return (
isRecord(value) &&
isNonEmptyString(value['hostId']) &&
isNonEmptyString(value['leaseId']) &&
isNonNegativeInteger(value['attempt']) &&
isFiniteTimestamp(value['expiresAt']) &&
isFiniteTimestamp(value['acquiredAt'])
);
}
function isValidA2AGrant(value: unknown): value is A2AGrant {
return (
isRecord(value) &&
isNonEmptyString(value['callerId']) &&
isValidId(value['agentId']) &&
(value['scope'] === 'analysis' || value['scope'] === 'full') &&
isNonEmptyString(value['secretHash']) &&
isFiniteTimestamp(value['createdAt']) &&
(value['expiresAt'] === undefined || isFiniteTimestamp(value['expiresAt']))
);
}
function isValidWorkspace(value: unknown): value is AgentWorkspaceState {
return (
isRecord(value) &&
@ -497,66 +417,10 @@ function isValidWorkspace(value: unknown): value is AgentWorkspaceState {
isValidId(value['workspaceId']) &&
(value['hostSessionId'] === undefined ||
isNonEmptyString(value['hostSessionId'])) &&
isPositiveInteger(value['nextRunSequence']) &&
// A malformed grant list fails the whole record rather than being dropped.
// Dropping it would silently revoke every external caller — or, if the
// malformed entry were the one being read past, silently admit one.
(value['callerGrants'] === undefined ||
(Array.isArray(value['callerGrants']) &&
value['callerGrants'].every(isValidA2AGrant)))
isPositiveInteger(value['nextRunSequence'])
);
}
function isValidAgentHost(value: unknown): value is AgentHost {
return (
isRecord(value) &&
isValidId(value['id']) &&
isNonEmptyString(value['name']) &&
isNonEmptyString(value['secretHash']) &&
isNonEmptyString(value['workspaceCwd']) &&
Array.isArray(value['providers']) &&
value['providers'].every(isNonEmptyString) &&
isFiniteTimestamp(value['createdAt']) &&
(value['lastSeenAt'] === undefined ||
isFiniteTimestamp(value['lastSeenAt']))
);
}
function isValidAgentHostsFile(value: unknown): value is AgentHostsFile {
if (
!isRecord(value) ||
value['schemaVersion'] !== AGENT_HOSTS_SCHEMA_VERSION ||
!Array.isArray(value['hosts']) ||
!value['hosts'].every(isValidAgentHost)
) {
return false;
}
const ids = new Set((value['hosts'] as AgentHost[]).map((host) => host.id));
if (ids.size !== value['hosts'].length) return false;
const enrollment = value['enrollment'];
return (
enrollment === undefined ||
(isRecord(enrollment) &&
isNonEmptyString(enrollment['tokenHash']) &&
isFiniteTimestamp(enrollment['expiresAt']))
);
}
function hashAgentHostSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function matchesAgentHostSecret(secret: string, expectedHash: string): boolean {
const actual = Buffer.from(hashAgentHostSecret(secret), 'hex');
const expected = Buffer.from(expectedHash, 'hex');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function publicAgentHost(host: AgentHost): AgentHostView {
const { secretHash: _secretHash, ...view } = host;
return view;
}
function assertKnownVersion(value: unknown, filePath: string): void {
if (!isRecord(value) || value['schemaVersion'] === undefined) {
throw new AgentSchemaVersionError(filePath, undefined);
@ -1160,197 +1024,6 @@ export async function readAgentWorkspace(
});
}
async function readAgentHostsUnlocked(
projectRoot: string,
): Promise<AgentHostsFile> {
const filePath = getAgentHostsFilePath(projectRoot);
const parsed = await readJsonFile(filePath);
if (parsed === undefined) {
return { schemaVersion: AGENT_HOSTS_SCHEMA_VERSION, hosts: [] };
}
if (!isValidAgentHostsFile(parsed)) {
throw new Error(`Malformed Agent Host registry in ${filePath}.`);
}
return parsed;
}
async function writeAgentHostsUnlocked(
projectRoot: string,
registry: AgentHostsFile,
): Promise<void> {
if (!isValidAgentHostsFile(registry)) {
throw new Error('Refusing to write malformed Agent Host registry.');
}
await atomicWriteJSON(getAgentHostsFilePath(projectRoot), registry, {
noFollow: true,
});
}
export async function readAgentHosts(
projectRoot: string,
): Promise<AgentHostView[]> {
return withWorkspaceLock(projectRoot, async () => {
await ensureMigratedUnlocked(projectRoot);
const registry = await readAgentHostsUnlocked(projectRoot);
return registry.hosts.map(publicAgentHost);
});
}
export async function issueAgentHostEnrollment(
projectRoot: string,
): Promise<{ token: string; expiresAt: number }> {
return withWorkspaceLock(projectRoot, async () => {
await ensureMigratedUnlocked(projectRoot);
const registry = await readAgentHostsUnlocked(projectRoot);
const token = randomBytes(32).toString('base64url');
const expiresAt = Date.now() + HOST_ENROLLMENT_TTL_MS;
await writeAgentHostsUnlocked(projectRoot, {
...registry,
enrollment: { tokenHash: hashAgentHostSecret(token), expiresAt },
});
return { token, expiresAt };
});
}
export async function enrollAgentHost(
projectRoot: string,
input: {
token: string;
name: string;
workspaceCwd: string;
providers: string[];
},
): Promise<{ host: AgentHostView; secret: string }> {
const name = input.name.trim();
const workspaceCwd = input.workspaceCwd.trim();
const providers = [...new Set(input.providers.map((value) => value.trim()))];
if (!input.token || !name || name.length > 80) {
throw new Error('Invalid Agent Host enrollment.');
}
if (!workspaceCwd || workspaceCwd.length > 4_096) {
throw new Error('Invalid Agent Host workspace.');
}
if (
providers.length === 0 ||
providers.length > 20 ||
providers.some((provider) => !provider || provider.length > 80)
) {
throw new Error('Invalid Agent Host providers.');
}
return withWorkspaceLock(projectRoot, async () => {
await ensureMigratedUnlocked(projectRoot);
const registry = await readAgentHostsUnlocked(projectRoot);
if (
!registry.enrollment ||
registry.enrollment.expiresAt < Date.now() ||
!matchesAgentHostSecret(input.token, registry.enrollment.tokenHash)
) {
throw new Error('Invalid or expired Agent Host enrollment token.');
}
const secret = randomBytes(32).toString('base64url');
const host: AgentHost = {
id: generateAgentHostId(),
name,
secretHash: hashAgentHostSecret(secret),
workspaceCwd,
providers,
createdAt: Date.now(),
};
const { enrollment: _used, ...rest } = registry;
await writeAgentHostsUnlocked(projectRoot, {
...rest,
hosts: [...registry.hosts, host],
});
return { host: publicAgentHost(host), secret };
});
}
export async function heartbeatAgentHost(
projectRoot: string,
hostId: string,
secret: string,
input: { workspaceCwd: string; providers: string[] },
): Promise<AgentHostView | undefined> {
return withWorkspaceLock(projectRoot, async () => {
await ensureMigratedUnlocked(projectRoot);
const registry = await readAgentHostsUnlocked(projectRoot);
const current = registry.hosts.find((host) => host.id === hostId);
if (!current || !matchesAgentHostSecret(secret, current.secretHash)) {
return undefined;
}
const workspaceCwd = input.workspaceCwd.trim();
const providers = [
...new Set(input.providers.map((value) => value.trim())),
];
if (
!workspaceCwd ||
workspaceCwd.length > 4_096 ||
providers.length === 0 ||
providers.length > 20 ||
providers.some((provider) => !provider || provider.length > 80)
) {
throw new Error('Invalid Agent Host heartbeat.');
}
const next: AgentHost = {
...current,
workspaceCwd,
providers,
lastSeenAt: Date.now(),
};
await writeAgentHostsUnlocked(projectRoot, {
...registry,
hosts: registry.hosts.map((host) =>
host.id === current.id ? next : host,
),
});
return publicAgentHost(next);
});
}
export async function authenticateAgentHost(
projectRoot: string,
hostId: string,
secret: string,
): Promise<AgentHostView | undefined> {
return withWorkspaceLock(projectRoot, async () => {
await ensureMigratedUnlocked(projectRoot);
const host = (await readAgentHostsUnlocked(projectRoot)).hosts.find(
(candidate) => candidate.id === hostId,
);
return host && matchesAgentHostSecret(secret, host.secretHash)
? publicAgentHost(host)
: undefined;
});
}
/**
* Read-modify-write the caller grants under the workspace lock.
*
* A read followed by a separate write would let two concurrent issues drop one
* another — and a dropped grant is a caller who thinks it has access and does
* not, or worse, one whose revocation silently did not take.
*/
export async function updateAgentWorkspaceCallerGrants(
projectRoot: string,
update: (grants: readonly A2AGrant[]) => A2AGrant[],
): Promise<AgentWorkspaceState> {
return withWorkspaceLock(projectRoot, async () => {
const workspace = await ensureMigratedUnlocked(projectRoot);
const grants = update(workspace.callerGrants ?? []);
const next: AgentWorkspaceState =
grants.length > 0
? { ...workspace, callerGrants: grants }
: (() => {
const { callerGrants: _dropped, ...rest } = workspace;
return rest;
})();
await atomicWriteJSON(getWorkspaceFilePath(projectRoot), next, {
noFollow: true,
});
return next;
});
}
export async function claimAgentHostSession(
projectRoot: string,
candidateSessionId: string,
@ -1414,8 +1087,7 @@ type WorkspaceAgentRosterChange =
| 'updated'
| 'not_found'
| 'has_live_work'
| 'retired'
| 'host_not_found';
| 'retired';
async function agentHasLiveWork(
transaction: AgentStoreTransaction,
@ -1462,36 +1134,6 @@ export async function setWorkspaceAgentEnabled(
});
}
export async function setWorkspaceAgentExecution(
projectRoot: string,
agentId: string,
execution: WorkspaceAgent['execution'],
): Promise<WorkspaceAgentRosterChange> {
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const agents = await transaction.readAgents();
const agent = agents.find((candidate) => candidate.id === agentId);
if (!agent) return 'not_found';
if (agent.retiredAt !== undefined) return 'retired';
if (await agentHasLiveWork(transaction, agentId)) return 'has_live_work';
if (execution?.mode === 'managed-host') {
const known = new Set(
(await readAgentHostsUnlocked(projectRoot)).hosts.map(
(host) => host.id,
),
);
if (execution.hostIds.some((hostId) => !known.has(hostId))) {
return 'host_not_found';
}
}
await transaction.writeAgents(
agents.map((candidate) =>
candidate.id === agentId ? { ...candidate, execution } : candidate,
),
);
return 'updated';
});
}
/**
* Retires an identity: it takes no new work and keeps everything it did.
*
@ -1541,20 +1183,6 @@ export function isAgentEnabled(agent: WorkspaceAgent): boolean {
return agent.enabled !== false;
}
export function isAgentLocal(agent: WorkspaceAgent): boolean {
return agent.execution === undefined || agent.execution.mode === 'local';
}
export function isAgentExecutableByHost(
agent: WorkspaceAgent,
hostId: string,
): boolean {
return (
agent.execution?.mode === 'managed-host' &&
agent.execution.hostIds.includes(hostId)
);
}
/** How many threads this agent may work at once. Absent means one. */
export function maxConcurrentRunsFor(agent: WorkspaceAgent): number {
return agent.maxConcurrentRuns ?? 1;
@ -1614,8 +1242,6 @@ export interface CreateThreadInput {
createdBy?: string;
assigneeAgentId?: string;
parentThreadId?: string;
/** Provenance when an external A2A caller raised this thread. */
externalIntake?: ExternalIntake;
}
/**
@ -1624,7 +1250,7 @@ export interface CreateThreadInput {
* Use `prepareThreadInTransaction` when the first message and run must be part
* of the initial file replacement too.
*/
export async function createThreadInTransaction(
async function createThreadInTransaction(
transaction: AgentStoreTransaction,
input: CreateThreadInput,
): Promise<Thread> {
@ -1681,7 +1307,6 @@ export async function prepareThreadInTransaction(
...(input.priority && input.priority !== DEFAULT_THREAD_PRIORITY
? { priority: input.priority }
: {}),
...(input.externalIntake ? { externalIntake: input.externalIntake } : {}),
};
}

View file

@ -17,66 +17,12 @@
export const HUMAN_AUTHOR_ID = 'user';
export const AGENTS_SCHEMA_VERSION = 1;
export const AGENT_HOSTS_SCHEMA_VERSION = 1;
export const LOCAL_AGENT_RUNTIME_ID = 'local';
export interface AgentHost {
id: string;
name: string;
secretHash: string;
workspaceCwd: string;
providers: string[];
createdAt: number;
lastSeenAt?: number;
}
export type AgentHostView = Omit<AgentHost, 'secretHash'>;
export interface AgentHostEnrollment {
tokenHash: string;
expiresAt: number;
}
export interface AgentHostsFile {
schemaVersion: typeof AGENT_HOSTS_SCHEMA_VERSION;
hosts: AgentHost[];
enrollment?: AgentHostEnrollment;
}
/**
* How much an external caller may ask of one agent.
*
* `analysis` is read-only work, which is what the plan opens first; `full`
* is everything that agent can do. Coarse on purpose — a scope nobody can
* read is a scope nobody enforces correctly.
*/
export type A2AGrantScope = 'analysis' | 'full';
/**
* One external caller's permission to call one agent.
*
* Per agent, never per daemon: opening agent A says nothing about agent B, and
* a grant in one direction confers nothing in the other. The secret is stored
* only as a digest and never travels in a thread, a prompt, a tool argument
* or a log line.
*/
export interface A2AGrant {
callerId: string;
agentId: string;
scope: A2AGrantScope;
secretHash: string;
createdAt: number;
/** Absent means it does not expire on its own; revocation still applies. */
expiresAt?: number;
}
export interface AgentWorkspaceState {
schemaVersion: typeof AGENTS_SCHEMA_VERSION;
workspaceId: string;
hostSessionId?: string;
nextRunSequence: number;
/** External callers allowed in, and to which agent. Absent means none. */
callerGrants?: A2AGrant[];
}
export interface WorkspaceAgentsFile {
@ -84,15 +30,6 @@ export interface WorkspaceAgentsFile {
agents: WorkspaceAgent[];
}
export type WorkspaceAgentExecution =
| { mode: 'local' }
| {
mode: 'managed-host';
hostIds: string[];
/** The program to run on the host; the host's default when absent. */
provider?: 'qwen' | 'codex';
};
/**
* A durable agent identity, scoped to one workspace.
*
@ -157,8 +94,6 @@ export interface WorkspaceAgent {
* Distinct from {@link queueLimit}, which bounds how much may wait.
*/
maxConcurrentRuns?: number;
/** Where this workspace-scoped identity may execute. Absent means local. */
execution?: WorkspaceAgentExecution;
}
/**
@ -282,17 +217,6 @@ export type ThreadRunStatus =
* treat it as a crash and revive it. A stranded run waits for a person, who
* decides whether to re-raise the work or drop it — the system does neither.
*/
/** One Host's temporary hold on a run. */
export interface RunLease {
hostId: string;
/** Minted fresh on every acquisition; never reused across attempts. */
leaseId: string;
/** The run attempt this lease is for. A later attempt invalidates it. */
attempt: number;
expiresAt: number;
acquiredAt: number;
}
export type RunCloseKind =
| 'waiting'
| 'blocked'
@ -359,16 +283,6 @@ export interface ThreadRun {
*/
usageBaselineTokens?: number;
failureStage?: string;
/**
* The outbound Host currently holding this run, if any.
*
* A lease rather than an assignment: a Host on the far side of a NAT can
* vanish without saying so, and work has to become available again without
* a person intervening. What makes that safe is that re-leasing mints a new
* `leaseId` and the attempt moves on, so the vanished worker's late write is
* refused rather than overwriting whoever picked the work up next.
*/
lease?: RunLease;
/** Workspace-wide FIFO key. */
queueSequence: number;
/**
@ -391,36 +305,6 @@ export interface ThreadRun {
* it is a prompt-injection surface by construction; keeping it out of the
* repo means it is never committed, pulled, or reviewed as if it were code.
*/
/**
* Provenance of a thread raised by an external A2A caller.
*
* Lives on the thread rather than in an index of its own so there is one
* source of truth: an index would be a second write, and a second write is a
* thing that can disagree with the first about whether work was accepted.
* Lookup by `key` is a scan, which costs the same as the other store scans and
* cannot go stale.
*/
export interface ExternalIntake {
/**
* `externalRequestKey(callerId, targetAgentId, messageId)`. Written in the
* same transaction that accepts the work — a key written afterwards cannot
* answer whether a retry arriving mid-acceptance is the same request.
*/
key: string;
/** Authenticated caller, from the transport. Scopes every read back. */
callerId: string;
targetAgentId: string;
/** `Message.messageId` as the caller minted it. */
messageId: string;
/**
* Digest of the submitted content. The protocol lets a caller reuse an id;
* this is what turns "same key, different content" into a refusal instead of
* a silent overwrite of work already accepted.
*/
contentHash: string;
receivedAt: number;
}
export interface Thread {
schemaVersion: typeof AGENTS_SCHEMA_VERSION;
id: string;
@ -442,8 +326,6 @@ export interface Thread {
priority?: ThreadPriority;
/** Agent that owns the thread when no message names someone explicitly. */
assigneeAgentId?: string;
/** Set when an external A2A caller raised this thread; see {@link ExternalIntake}. */
externalIntake?: ExternalIntake;
createdAt: number;
/** {@link HUMAN_AUTHOR_ID} or an agent id. */
createdBy: string;

View file

@ -9030,7 +9030,7 @@ export function App({
);
}, []);
const [agentsNav, setAgentsNav] = useState<{
view: 'agents' | 'tasks' | 'runtime' | 'new-agent';
view: 'agents' | 'tasks' | 'new-agent';
request: number;
}>({ view: 'agents', request: 0 });
useEffect(() => {

View file

@ -51,15 +51,6 @@ interface AgentCreatePageProps {
agent?: DaemonWorkspaceAgentDetail;
onCancel: () => void;
onCreated: (name: string) => void;
executionHosts?: readonly {
id: string;
label: string;
status: 'online' | 'offline';
provider?: string;
workspaceCwd?: string;
}[];
/** Preselects the runtime, e.g. right after it joined. */
initialHostId?: string;
onSaveWorkspaceAgent?: (input: {
name: string;
description?: string;
@ -67,13 +58,6 @@ interface AgentCreatePageProps {
agentType?: string;
model?: string;
maxConcurrentRuns: number;
execution?:
| { mode: 'local' }
| {
mode: 'managed-host';
hostIds: string[];
provider?: 'qwen' | 'codex';
};
}) => Promise<void>;
}
@ -120,9 +104,7 @@ export function AgentCreatePage({
agent,
onCancel,
onCreated,
executionHosts = [],
workspaceCwd,
initialHostId,
onSaveWorkspaceAgent,
}: AgentCreatePageProps) {
const { t } = useI18n();
@ -168,12 +150,6 @@ export function AgentCreatePage({
approvalMode === 'bubble' ? [...approvalModes, 'bubble'] : approvalModes;
const [maxTurns, setMaxTurns] = useState(agent?.maxTurns?.toString() ?? '');
const [maxConcurrentRuns, setMaxConcurrentRuns] = useState('1');
const [executionProvider, setExecutionProvider] = useState<'qwen' | 'codex'>(
'qwen',
);
const [executionHostIds, setExecutionHostIds] = useState(
() => new Set<string>(initialHostId ? [initialHostId] : []),
);
const [role, setRole] = useState('');
const [color, setColor] = useState(agent?.color ?? 'inherit');
const [selectedMcpServers, setSelectedMcpServers] = useState(
@ -523,15 +499,6 @@ export function AgentCreatePage({
...(role ? { agentType: role } : {}),
...(model.trim() ? { model: model.trim() } : {}),
maxConcurrentRuns: concurrency,
...(executionHostIds.size > 0
? {
execution: {
mode: 'managed-host' as const,
hostIds: [...executionHostIds],
provider: executionProvider,
},
}
: {}),
});
onCreated(trimmedName);
return;
@ -784,109 +751,6 @@ export function AgentCreatePage({
}
/>
</Field>
<Field className="lg:col-span-2">
<FieldLabel>{t('collab.agent.runsOn')}</FieldLabel>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex items-start gap-2 rounded-md border border-border p-3 text-sm">
<input
type="radio"
name="agent-execution-location"
checked={executionHostIds.size === 0}
onChange={() => {
setExecutionHostIds(new Set());
setExecutionProvider('qwen');
}}
/>
<span>
{t('collab.agent.thisComputer')}
<span className="block break-all text-xs text-muted-foreground">
{workspaceCwd}
</span>
</span>
</label>
{executionHosts.map((host) => (
<label
key={host.id}
className="flex items-start gap-2 rounded-md border border-border p-3 text-sm"
>
<input
type="radio"
name="agent-execution-location"
checked={executionHostIds.has(host.id)}
onChange={() => {
setExecutionHostIds(new Set([host.id]));
setExecutionProvider('qwen');
}}
/>
<span>
{host.label}
{host.status === 'offline' && (
<span className="ml-2 text-xs text-muted-foreground">
{t('collab.agentStatus.offline')}
</span>
)}
<span className="block break-all text-xs text-muted-foreground">
{host.workspaceCwd ?? t('collab.agent.cwdUnknown')}
</span>
</span>
</label>
))}
</div>
<FieldDescription>
{t('collab.agent.runsOnHint')}
</FieldDescription>
</Field>
{(() => {
// Only what the chosen runtime reported it can run is
// selectable; the rest say why not.
const host = executionHosts.find((entry) =>
executionHostIds.has(entry.id),
);
const missing = (provider: 'qwen' | 'codex') =>
provider === 'qwen'
? undefined
: !host
? t('collab.agent.programLocal')
: /codex/i.test(host.provider ?? '')
? undefined
: t('collab.agent.programMissing');
return (
<Field className="lg:col-span-2">
<FieldLabel>{t('collab.runtime.program')}</FieldLabel>
<div
role="radiogroup"
aria-label={t('collab.runtime.program')}
className="grid gap-2 sm:grid-cols-2"
>
{(['qwen', 'codex'] as const).map((provider) => {
const reason = missing(provider);
return (
<label
key={provider}
className="flex items-start gap-2 rounded-md border border-border p-3 text-sm has-[:disabled]:opacity-60"
>
<input
type="radio"
name="agent-execution-provider"
disabled={reason !== undefined}
checked={executionProvider === provider}
onChange={() => setExecutionProvider(provider)}
/>
<span>
{provider === 'qwen' ? 'Qwen Code' : 'Codex'}
{reason && (
<span className="block text-xs text-muted-foreground">
{reason}
</span>
)}
</span>
</label>
);
})}
</div>
</Field>
);
})()}
<Field className="lg:col-span-2">
<FieldDescription>
{t('collab.agent.afterCreate')}

View file

@ -82,7 +82,7 @@ import type { EmbeddedManagerPage } from '../plugins/manager-page';
import styles from './AgentsManagerPage.module.css';
interface AgentsManagerPageProps {
initialAgentView?: 'agents' | 'tasks' | 'runtime' | 'new-agent';
initialAgentView?: 'agents' | 'tasks' | 'new-agent';
onClose: () => void;
embedded?: EmbeddedManagerPage;
initialCreateScope?: 'workspace' | 'global' | null;

View file

@ -408,7 +408,7 @@ interface WebShellSidebarProps {
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onOpenSettings: () => void;
onOpenAgents?: (view?: 'agents' | 'tasks' | 'runtime') => void;
onOpenAgents?: (view?: 'agents' | 'tasks') => void;
onOpenPlugins: () => void;
onOpenChannels: () => void;
onOpenDaemonStatus: () => void;

View file

@ -53,18 +53,12 @@ function formatElapsed(
*/
function describeLiveRun(
run: RunView,
hostOffline: boolean,
now: number,
t: (key: string, vars?: Record<string, string | number>) => string,
): { text: string; stalled: boolean } {
const agent = run.agentName;
if (run.status === 'queued') {
return {
text: hostOffline
? t('collab.run.hostOffline', { agent })
: t('collab.run.queued', { agent }),
stalled: false,
};
return { text: t('collab.run.queued', { agent }), stalled: false };
}
if (run.status === 'cancelling') {
return { text: t('collab.run.stopping', { agent }), stalled: false };
@ -188,7 +182,6 @@ function teamMembers(
/** A member's one-word state, for the team list. */
function memberStatus(
member: TeamMember,
agents: readonly { id: string; runtime?: { status: string } }[],
now: number,
t: (key: string, vars?: Record<string, string | number>) => string,
): { text: string; tone: string } {
@ -199,10 +192,7 @@ function memberStatus(
if (!run) return { text: t('collab.member.idle'), tone: muted };
switch (run.status) {
case 'queued':
return agents.find((agent) => agent.id === run.agentId)?.runtime
?.status === 'offline'
? { text: t('collab.member.offline'), tone: attention }
: { text: t('collab.member.queued'), tone: muted };
return { text: t('collab.member.queued'), tone: muted };
case 'running':
case 'finishing':
case 'cancelling': {
@ -261,8 +251,6 @@ export function ThreadChat({
name: string;
enabled: boolean;
retiredAt?: number;
status?: string;
runtime?: { label: string; status: string };
}[];
thread: ThreadDetailView;
pending: boolean;
@ -384,7 +372,7 @@ export function ThreadChat({
)}
<ul className="mb-4">
{members.map((member) => {
const { text, tone } = memberStatus(member, agents, now, t);
const { text, tone } = memberStatus(member, now, t);
const sessionId = member.run?.sessionId;
return (
<li key={member.name}>
@ -429,7 +417,6 @@ export function ThreadChat({
<RunRowView
key={row.run.id}
row={row}
agent={agents.find((agent) => agent.id === row.run.agentId)}
onOpenAgentSession={onOpenAgentSession}
onCancelRun={pending ? undefined : onCancelRun}
/>
@ -629,10 +616,7 @@ export function ThreadChat({
</div>
);
}
const hostOffline =
agents.find((agent) => agent.id === run.agentId)?.runtime
?.status === 'offline';
const described = describeLiveRun(run, hostOffline, now, t);
const described = describeLiveRun(run, now, t);
const stalled =
described.stalled && now >= (snoozedUntil[run.id] ?? 0);
const text = stalled

View file

@ -81,8 +81,6 @@ export interface ThreadViewProps {
enabled: boolean;
/** Retired identities stay in the list and are never offered new work. */
retiredAt?: number;
status?: string;
runtime?: { label: string; status: string };
}[];
/** Server-computed routing for the current draft. */
preview?: readonly RoutingPreviewTarget[];
@ -111,12 +109,10 @@ function formatTime(at: number): string {
export function RunRowView({
row,
agent,
onOpenAgentSession,
onCancelRun,
}: {
row: RunRow;
agent?: { status?: string; runtime?: { label: string; status: string } };
onOpenAgentSession?: (sessionId: string) => void;
onCancelRun?: (runId: string) => void;
}) {
@ -140,9 +136,7 @@ export function RunRowView({
};
const state =
row.run.status === 'queued'
? agent?.status === 'offline' || agent?.runtime?.status === 'offline'
? `执行主机 ${agent.runtime?.label ?? ''} 离线,等待恢复`
: '消息已接收,排队等待启动'
? '消息已接收,排队等待启动'
: row.run.status === 'running'
? progress
? stale
@ -571,7 +565,6 @@ export function ThreadView({
<RunRowView
key={row.run.id}
row={row}
agent={agents.find((agent) => agent.name === row.run.agentName)}
{...(onOpenAgentSession ? { onOpenAgentSession } : {})}
{...(onCancelRun ? { onCancelRun } : {})}
/>

View file

@ -52,88 +52,6 @@
border-bottom: 1px solid var(--border);
}
.runtimeCard {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px 20px;
align-items: center;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.enrollmentCard {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--muted-foreground);
font-size: 12px;
}
.enrollmentCard code {
overflow-x: auto;
padding: 10px;
border-radius: calc(var(--radius) - 2px);
background: var(--muted);
color: var(--foreground);
white-space: nowrap;
}
.runtimeHeader {
min-width: 0;
}
.runtimeTitle {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
}
.runtimeStatus {
color: var(--status-running-fg);
font-size: 12px;
text-transform: capitalize;
}
.runtimeStatus[data-runtime-status='offline'] {
color: var(--muted-foreground);
}
.runtimeFacts {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 20px;
margin: 10px 0 0;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.runtimeFacts div {
min-width: 0;
}
.runtimeFacts dt {
color: var(--muted-foreground);
font-size: 11px;
}
.runtimeFacts dd {
margin: 2px 0 0;
overflow: hidden;
color: var(--foreground);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtimeFacts code {
color: var(--muted-foreground);
font-size: 12px;
}
.sectionTitle {
margin-bottom: 6px;
color: var(--muted-foreground);
@ -225,10 +143,6 @@
color: var(--foreground);
}
.agentRemove:hover:not(:disabled) {
color: var(--status-attention-fg);
}
.agentAction:disabled {
opacity: 0.4;
cursor: default;

View file

@ -5,12 +5,6 @@
*/
import { useI18n } from '../../i18n';
import { AddRuntimeDialog, type JoinToken } from './add-runtime-dialog';
import {
ShareAgentDialog,
type AgentShare,
type AgentShareSummary,
} from './share-agent-dialog';
import { useMemo, useState, type FormEvent } from 'react';
import { MoreHorizontalIcon, PlusIcon } from 'lucide-react';
@ -51,9 +45,6 @@ export interface AgentConfigPatch {
instructions?: string | null;
agentType?: string | null;
maxConcurrentRuns?: number | null;
execution?:
| { mode: 'local' }
| { mode: 'managed-host'; hostIds: string[]; provider?: 'qwen' | 'codex' };
}
/** What every agent in this workspace may do. A property of the subsystem. */
@ -64,38 +55,17 @@ export interface AgentCapabilitiesView {
}
export interface ThreadsPageProps {
onConnectRemoteHost?: (input: {
remoteUrl: string;
remoteToken: string;
remoteCwd: string;
serverUrl: string;
provider: 'qwen' | 'codex';
allowHttp: boolean;
}) => Promise<boolean>;
createError?: string;
agents: readonly WorkspaceAgentSummaryView[];
threads: readonly ThreadSummaryView[];
runtimes?: readonly WorkspaceAgentRuntimeView[];
view: AgentWorkspaceView;
onViewChange: (view: AgentWorkspaceView) => void;
onOpenThread: (threadId: string) => void;
onDeleteAgent: (agentId: string) => void;
onSetAgentEnabled: (agentId: string, enabled: boolean) => void;
onUpdateAgent?: (agentId: string, patch: AgentConfigPatch) => void;
onOpenAgentBuilder?: (hostId?: string) => void;
onOpenAgentBuilder?: () => void;
onOpenDefinitions?: () => void;
/** Issues a single-use join token for the Add runtime dialog. */
onCreateJoinToken?: () => Promise<JoinToken>;
/** A2A shares of one agent; absent hides Share. */
shares?: {
create: (
agentId: string,
scope: 'analysis' | 'full',
) => Promise<AgentShare>;
list: (agentId: string) => Promise<AgentShareSummary[]>;
revoke: (agentId: string, callerId: string) => Promise<unknown>;
};
hostServerUrl?: string;
capabilities?: AgentCapabilitiesView;
onCreateThread: (input: NewThread) => Promise<boolean> | void;
workspaceCwd?: string;
@ -117,10 +87,8 @@ export interface WorkspaceAgentSummaryView {
/** What this identity is told on top of its definition's prompt. */
instructions?: string;
maxConcurrentRuns?: number;
execution?: AgentConfigPatch['execution'];
enabled: boolean;
status: 'offline' | 'idle' | 'working' | 'blocked' | 'error';
runtime: WorkspaceAgentRuntimeView;
/** Set once the identity is retired: it keeps its posts and takes no work. */
retiredAt?: number;
workingOn?: {
@ -131,23 +99,7 @@ export interface WorkspaceAgentSummaryView {
waiting: number;
}
export interface WorkspaceAgentRuntimeView {
id: string;
kind: 'local' | 'external';
label: string;
provider: string;
status: 'online' | 'offline';
workspaceId?: string;
workspaceCwd?: string;
hostSessionId?: string;
lastSeenAt?: number;
agentCount?: number;
sessionCount?: number;
runningTaskCount?: number;
queuedTaskCount?: number;
}
export type AgentWorkspaceView = 'agents' | 'tasks' | 'runtime';
export type AgentWorkspaceView = 'agents' | 'tasks';
export interface NewWorkspaceAgent {
name: string;
@ -156,7 +108,6 @@ export interface NewWorkspaceAgent {
model?: string;
instructions?: string;
maxConcurrentRuns?: number;
execution?: AgentConfigPatch['execution'];
}
export type ThreadPriorityChoice = 'urgent' | 'high' | 'normal' | 'low';
@ -248,7 +199,6 @@ function Group({
export function ThreadsPage({
agents,
threads,
runtimes,
view,
onViewChange,
onOpenThread,
@ -257,10 +207,6 @@ export function ThreadsPage({
onUpdateAgent,
onOpenAgentBuilder,
onOpenDefinitions,
onCreateJoinToken,
shares,
onConnectRemoteHost,
hostServerUrl,
capabilities,
onCreateThread,
workspaceCwd,
@ -272,16 +218,12 @@ export function ThreadsPage({
pending,
}: ThreadsPageProps) {
const groups = useMemo(() => groupThreads(threads), [threads]);
const runtimeEntries = runtimes ?? [];
const [creating, setCreating] = useState<'thread'>();
const [configuring, setConfiguring] = useState<string>();
const [openAgentId, setOpenAgentId] = useState<string>();
const { t } = useI18n();
const [addingRuntime, setAddingRuntime] = useState(false);
const [sharing, setSharing] = useState<{ id: string; name: string }>();
const [taskAssignee, setTaskAssignee] = useState('');
const statusLabels: Record<string, string> = {
online: '在线',
offline: '离线',
idle: '空闲',
working: '执行中',
@ -291,16 +233,6 @@ export function ThreadsPage({
stopping: '停止中',
};
const statusLabel = (status: string) => statusLabels[status] ?? status;
const hostLabel = (entry: WorkspaceAgentRuntimeView) =>
entry.kind === 'local' ? t('collab.agent.thisComputer') : entry.label;
// "Program · Runtime": what the agent runs as, then where.
const agentPlace = (agent: WorkspaceAgentSummaryView) =>
`${
agent.execution?.mode === 'managed-host' &&
agent.execution.provider === 'codex'
? 'Codex'
: 'Qwen Code'
} · ${hostLabel(agent.runtime)}`;
const submitConfig =
(agentId: string) => (event: FormEvent<HTMLFormElement>) => {
@ -315,39 +247,12 @@ export function ThreadsPage({
return value === '' ? null : value;
};
const runs = String(data.get('maxConcurrentRuns') ?? '').trim();
const hostIds = data
.getAll('executionHostId')
.map((value) => String(value));
const current = agents.find((agent) => agent.id === agentId);
const currentHostIds =
current?.execution?.mode === 'managed-host'
? current.execution.hostIds
: [];
const placementChanged =
hostIds.length !== currentHostIds.length ||
hostIds.some((hostId) => !currentHostIds.includes(hostId));
onUpdateAgent(agentId, {
description: field('description'),
model: field('model'),
agentType: field('agentType'),
instructions: field('instructions'),
maxConcurrentRuns: runs === '' ? null : Number(runs),
...(placementChanged
? {
execution:
hostIds.length > 0
? ({
mode: 'managed-host',
hostIds,
// Moving an agent keeps the program it is bound to.
...(current?.execution?.mode === 'managed-host' &&
current.execution.provider
? { provider: current.execution.provider }
: {}),
} as const)
: ({ mode: 'local' } as const),
}
: {}),
});
setConfiguring(undefined);
};
@ -388,7 +293,7 @@ export function ThreadsPage({
<div className={styles.page}>
<header className={styles.pageHeader}>
<nav className={styles.viewTabs} aria-label={t('agents.title')}>
{(['agents', 'tasks', 'runtime'] as const).map((item) => (
{(['agents', 'tasks'] as const).map((item) => (
<Button
key={item}
variant={view === item ? 'secondary' : 'ghost'}
@ -416,17 +321,6 @@ export function ThreadsPage({
{t('collab.agent.new')}
</Button>
) : null}
{view === 'runtime' && onCreateJoinToken ? (
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={() => setAddingRuntime(true)}
>
<PlusIcon data-icon="inline-start" />
{t('collab.runtime.addTitle')}
</Button>
) : null}
{view === 'tasks' ? (
<Button
size="sm"
@ -497,7 +391,7 @@ export function ThreadsPage({
</label>
<p className="text-xs text-muted-foreground">
<span className="block break-all">{workspaceCwd}</span>
与侧边栏的项目工作区相同,决定任务归属和可协作的智能体。默认当前项目;运行机器及执行目录由智能体的运行设置决定。
与侧边栏的项目工作区相同,决定任务归属和可协作的智能体。默认当前项目。
</p>
<input
className="w-full border-0 bg-transparent text-xl font-medium outline-none"
@ -550,7 +444,7 @@ export function ThreadsPage({
.filter((agent) => agent.enabled && !agent.retiredAt)
.map((agent) => (
<option key={agent.id} value={agent.name}>
{agent.name} · {agentPlace(agent)}
{agent.name}
</option>
))}
</select>
@ -647,9 +541,6 @@ export function ThreadsPage({
</button>
<span className={styles.agentDescription}>
{agent.description}
<span className="block text-xs text-muted-foreground">
{agentPlace(agent)}
</span>
</span>
{agent.workingOn ? (
<button
@ -703,15 +594,6 @@ export function ThreadsPage({
{t('collab.agent.configure')}
</DropdownMenuItem>
) : null}
{shares ? (
<DropdownMenuItem
onSelect={() =>
setSharing({ id: agent.id, name: agent.name })
}
>
{t('collab.agent.share')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={pending}
onSelect={() =>
@ -797,33 +679,6 @@ export function ThreadsPage({
defaultValue={agent.maxConcurrentRuns ?? 1}
/>
</label>
{runtimeEntries.some(
(entry) => entry.kind === 'external',
) ? (
<fieldset className={styles.configLabel}>
<legend>执行主机</legend>
{runtimeEntries
.filter((entry) => entry.kind === 'external')
.map((entry) => (
<label key={entry.id}>
<input
name="executionHostId"
type="checkbox"
value={entry.id}
defaultChecked={
agent.execution?.mode === 'managed-host' &&
agent.execution.hostIds.includes(entry.id)
}
/>{' '}
{hostLabel(entry)} · {entry.provider} ·{' '}
{statusLabel(entry.status)}
</label>
))}
<span className={styles.configNote}>
不选择外部主机时,由本机 Qwen Code 执行。
</span>
</fieldset>
) : null}
<p className={styles.configNote}>
清空字段后使用角色模板的默认配置。
</p>
@ -846,9 +701,7 @@ export function ThreadsPage({
<section className={styles.agentWorkspace}>
<div className={styles.agentWorkspaceHeader}>
<strong>已分配任务</strong>
<span>
{hostLabel(agent.runtime)} · {statusLabel(agent.status)}
</span>
<span>{statusLabel(agent.status)}</span>
</div>
{threads.some(
(thread) => thread.assigneeName === agent.name,
@ -879,7 +732,7 @@ export function ThreadsPage({
<h3 className={styles.ceilingTitle}>当前协作权限</h3>
<p className={styles.ceilingText}>
{capabilities.readOnly
? '当前 demo 以只读检查、派单和结果汇总为主,不开放修改文件的能力。职责指令不能提高工具权限;外部执行器还受自身权限设置约束。'
? '当前 demo 以只读检查、派单和结果汇总为主,不开放修改文件的能力。职责指令不能提高工具权限。'
: '当前未启用只读限制。'}
</p>
<details>
@ -898,7 +751,7 @@ export function ThreadsPage({
<div className={styles.emptyState} hidden={view !== 'tasks'}>
{/* An empty screen is an invitation, not a shrug. */}
<p className={styles.emptyLead}>还没有任务。</p>
<p>创建任务并指定智能体,系统会根据主机状态安排执行。</p>
<p>创建任务并指定智能体,系统会安排执行。</p>
</div>
) : (
groups.map((group) => (
@ -910,107 +763,7 @@ export function ThreadsPage({
/>
))
)}
{view === 'runtime' && runtimeEntries.length > 0 ? (
runtimeEntries.map((runtimeEntry) => (
<section className={styles.runtimeCard} key={runtimeEntry.id}>
<div className={styles.runtimeHeader}>
<h2 className={styles.runtimeTitle}>
{hostLabel(runtimeEntry)}
</h2>
<p className={styles.configNote}>
{runtimeEntry.kind === 'local'
? t('collab.runtime.localNote')
: t('collab.runtime.remoteNote')}
</p>
</div>
<strong
className={styles.runtimeStatus}
data-runtime-status={runtimeEntry.status}
>
{statusLabel(runtimeEntry.status)}
</strong>
<dl className={styles.runtimeFacts}>
<div>
<dt>{t('collab.runtime.programs')}</dt>
<dd>{runtimeEntry.provider}</dd>
</div>
{runtimeEntry.workspaceCwd ? (
<div>
<dt>工作目录</dt>
<dd>
<code>{runtimeEntry.workspaceCwd}</code>
</dd>
</div>
) : null}
<div>
<dt>关联智能体</dt>
<dd>{runtimeEntry.agentCount ?? 0}</dd>
</div>
<div>
<dt>执行中任务</dt>
<dd>{runtimeEntry.runningTaskCount ?? 0}</dd>
</div>
<div>
<dt>排队任务</dt>
<dd>{runtimeEntry.queuedTaskCount ?? 0}</dd>
</div>
</dl>
<details className="mt-4 text-xs text-muted-foreground">
<summary className="cursor-pointer">技术详情</summary>
<p>主机标识:{runtimeEntry.id}</p>
{runtimeEntry.hostSessionId && (
<p>宿主会话:{runtimeEntry.hostSessionId}</p>
)}
<p>会话数:{runtimeEntry.sessionCount ?? 0}</p>
{runtimeEntry.lastSeenAt && (
<p>
最近心跳:
{new Date(runtimeEntry.lastSeenAt).toLocaleTimeString()}
</p>
)}
</details>
</section>
))
) : view === 'runtime' ? (
<div className={styles.emptyState}>
<p className={styles.emptyLead}>{t('collab.runtime.empty')}</p>
<p>{t('collab.runtime.emptyHint')}</p>
</div>
) : null}
</div>
{shares && sharing && (
<ShareAgentDialog
agentName={sharing.name}
open
onOpenChange={(open) => {
if (!open) setSharing(undefined);
}}
onCreate={(scope) => shares.create(sharing.id, scope)}
onList={() => shares.list(sharing.id)}
onRevoke={(callerId) => shares.revoke(sharing.id, callerId)}
/>
)}
{onCreateJoinToken && (
<AddRuntimeDialog
open={addingRuntime}
onOpenChange={setAddingRuntime}
serverUrl={hostServerUrl ?? ''}
runtimes={runtimeEntries}
onCreateJoinToken={onCreateJoinToken}
{...(onConnectRemoteHost
? { onConnectExisting: onConnectRemoteHost }
: {})}
{...(onOpenAgentBuilder
? {
onCreateAgentOn: (runtimeId: string) => {
setAddingRuntime(false);
onOpenAgentBuilder(runtimeId);
},
}
: {})}
/>
)}
</div>
);
}

View file

@ -13,7 +13,6 @@ import {
import {
ThreadsPage,
type WorkspaceAgentSummaryView,
type WorkspaceAgentRuntimeView,
type AgentWorkspaceView,
type NewWorkspaceAgent,
type NewThread,
@ -34,36 +33,16 @@ import {
type AgentRunProgressEvent,
type AgentStreamState,
} from './agent-events';
import type { JoinToken } from './add-runtime-dialog';
import type { AgentShare, AgentShareSummary } from './share-agent-dialog';
interface CreateThreadResult {
id: string;
}
export interface ThreadsApi {
connectRemoteHost?(input: {
remoteUrl: string;
remoteToken: string;
remoteCwd: string;
serverUrl: string;
provider: 'qwen' | 'codex';
allowHttp: boolean;
}): Promise<unknown>;
listAgents(): Promise<{
agents: WorkspaceAgentSummaryView[];
runtime?: WorkspaceAgentRuntimeView;
runtimes?: WorkspaceAgentRuntimeView[];
capabilities?: AgentCapabilitiesView;
}>;
/** A single-use token for `qwen serve --join` on another machine. */
createJoinToken?(): Promise<JoinToken>;
createShare?(
agentId: string,
scope: 'analysis' | 'full',
): Promise<AgentShare>;
listShares?(agentId: string): Promise<{ shares: AgentShareSummary[] }>;
revokeShare?(agentId: string, callerId: string): Promise<unknown>;
listThreads(): Promise<{ threads: ThreadSummaryView[] }>;
getThread(id: string): Promise<ThreadDetailView>;
createAgent(input: NewWorkspaceAgent): Promise<unknown>;
@ -122,18 +101,7 @@ export function createThreadsHttpApi(
request<T>(path, { method: 'POST', body: JSON.stringify(body) });
return {
connectRemoteHost: (input) => post('/hosts/remote-connect', input),
listAgents: () => request('/agents'),
createJoinToken: () => post('/hosts/enrollment', {}),
createShare: (agentId, scope) =>
post(`/agents/${encodeURIComponent(agentId)}/shares`, { scope }),
listShares: (agentId) =>
request(`/agents/${encodeURIComponent(agentId)}/shares`),
revokeShare: (agentId, callerId) =>
request(
`/agents/${encodeURIComponent(agentId)}/shares/${encodeURIComponent(callerId)}`,
{ method: 'DELETE' },
),
listThreads: () => request('/threads'),
getThread: (id) => request(`/threads/${encodeURIComponent(id)}`),
createAgent: (input) => post('/agents', input),
@ -274,7 +242,6 @@ export function ThreadsRoute({
);
const { t } = useI18n();
const [agents, setAgents] = useState<WorkspaceAgentSummaryView[]>([]);
const [runtimes, setRuntimes] = useState<WorkspaceAgentRuntimeView[]>([]);
const [view, setView] = useState<AgentWorkspaceView>(
initialView === undefined || initialView === 'new-agent'
? 'agents'
@ -295,10 +262,10 @@ export function ThreadsRoute({
RoutingPreviewTarget[] | undefined
>();
const [pending, setPending] = useState(false);
// Set while the New agent page is open; may name the runtime to preselect.
const [creatingAgent, setCreatingAgent] = useState<
{ hostId?: string } | undefined
>(initialView === 'new-agent' ? {} : undefined);
// Set while the New agent page is open.
const [creatingAgent, setCreatingAgent] = useState(
initialView === 'new-agent',
);
const [refreshError, setRefreshError] = useState<string | undefined>();
const [actionError, setActionError] = useState<string | undefined>();
const error = actionError ?? refreshError;
@ -337,9 +304,6 @@ export function ThreadsRoute({
}
appliedRefresh.current = sequence;
setAgents(nextAgents.agents);
setRuntimes(
nextAgents.runtimes ?? (nextAgents.runtime ? [nextAgents.runtime] : []),
);
if (nextAgents.capabilities) setCapabilities(nextAgents.capabilities);
setThreads(nextThreads.threads);
setDetail(nextDetail);
@ -481,12 +445,8 @@ export function ThreadsRoute({
<AgentCreatePage
initialScope="workspace"
workspaceCwd={workspaceCwd}
executionHosts={runtimes.filter((entry) => entry.kind === 'external')}
{...(creatingAgent.hostId
? { initialHostId: creatingAgent.hostId }
: {})}
onCancel={() => setCreatingAgent(undefined)}
onCreated={() => setCreatingAgent(undefined)}
onCancel={() => setCreatingAgent(false)}
onCreated={() => setCreatingAgent(false)}
onSaveWorkspaceAgent={async (input) => {
await client.createAgent(input);
await refresh();
@ -637,12 +597,6 @@ export function ThreadsRoute({
threads={threads}
view={view}
onViewChange={setView}
runtimes={runtimes}
onConnectRemoteHost={
client.connectRemoteHost
? (input) => mutate(() => client.connectRemoteHost!(input))
: undefined
}
createPreview={createPreview}
pending={pending}
onOpenThread={openThread}
@ -653,24 +607,10 @@ export function ThreadsRoute({
onUpdateAgent={(id, patch) =>
void mutate(() => client.updateAgent(id, patch))
}
onOpenAgentBuilder={(hostId) => setCreatingAgent({ hostId })}
{...(client.createJoinToken
? { onCreateJoinToken: client.createJoinToken }
: {})}
{...(client.createShare && client.listShares && client.revokeShare
? {
shares: {
create: client.createShare,
list: async (agentId: string) =>
(await client.listShares!(agentId)).shares,
revoke: client.revokeShare,
},
}
: {})}
onOpenAgentBuilder={() => setCreatingAgent(true)}
{...(onOpenDefinitions ? { onOpenDefinitions } : {})}
{...(capabilities ? { capabilities } : {})}
workspaceCwd={workspaceCwd}
hostServerUrl={workspace.baseUrl}
workspaces={workspace.capabilities?.workspaces ?? []}
onWorkspaceChange={(cwd) => {
setSelectedWorkspaceCwd(cwd);

View file

@ -1,412 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { CheckCircle2, CopyIcon, LoaderCircle } from 'lucide-react';
import { useI18n } from '../../i18n';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { Input } from '../ui/input';
import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
export interface RuntimeSummary {
id: string;
kind: 'local' | 'external';
label: string;
provider: string;
providers?: readonly string[];
status: 'online' | 'offline';
}
export interface JoinToken {
token: string;
workspaceId: string;
expiresAt: number;
}
export interface ConnectExistingInput {
remoteUrl: string;
remoteToken: string;
remoteCwd: string;
serverUrl: string;
provider: 'qwen' | 'codex';
allowHttp: boolean;
}
function onlineIds(runtimes: readonly RuntimeSummary[]): Set<string> {
return new Set(
runtimes
.filter((runtime) => runtime.status === 'online')
.map((runtime) => runtime.id),
);
}
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
function joinCommands(address: string, join: JoinToken) {
const url = new URL(address);
const base = `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
const link = `${base}/join/${encodeURIComponent(join.workspaceId)}/${encodeURIComponent(join.token)}`;
// A host refuses plain HTTP off loopback unless told the network is trusted.
const insecure = url.protocol === 'http:' && !LOOPBACK.has(url.hostname);
const args = `serve --no-web --port 0 --join '${link}'${insecure ? ' --agent-host-allow-http' : ''}`;
return {
qwen: `qwen ${args}`,
npx: `npx -y @qwen-code/qwen-code@latest ${args}`,
insecure,
};
}
function CommandLine({ text }: { text: string }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1.5 pr-1.5 pl-3">
<code className="min-w-0 flex-1 truncate font-mono text-xs">{text}</code>
<Button
size="sm"
variant="outline"
onClick={() => {
void writeClipboardText(text).then(
() => setCopied(true),
(error: unknown) => warnClipboardWriteFailure(error),
);
}}
>
<CopyIcon aria-hidden="true" />
{copied ? t('collab.runtime.copied') : t('collab.runtime.copy')}
</Button>
</div>
);
}
/**
* "Add runtime": hand another machine a one-line command, then wait for it to
* show up. The dialog notices the new runtime through the same live stream
* that updates the Runtime tab, so it flips from waiting to connected by
* itself (the Tailscale/Vercel "add device" pattern).
*/
export function AddRuntimeDialog({
open,
onOpenChange,
serverUrl,
runtimes,
onCreateJoinToken,
onConnectExisting,
onCreateAgentOn,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The coordinator address this page talks to; the link's default. */
serverUrl: string;
runtimes: readonly RuntimeSummary[];
onCreateJoinToken: () => Promise<JoinToken>;
onConnectExisting?: (input: ConnectExistingInput) => Promise<boolean>;
onCreateAgentOn?: (runtimeId: string) => void;
}) {
const { t } = useI18n();
const [method, setMethod] = useState<'command' | 'existing'>('command');
const [address, setAddress] = useState(serverUrl);
const [join, setJoin] = useState<JoinToken>();
// Runtimes online when we started waiting; one that is online now and was
// not is the one that joined. Online, not present: a machine that joined
// before reuses its saved identity and comes back under the same id.
const [watch, setWatch] = useState<{
at: number;
known: ReadonlySet<string>;
}>();
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const [now, setNow] = useState(() => Date.now());
const connected = useMemo(
() =>
watch
? runtimes.find(
(runtime) =>
runtime.kind === 'external' &&
runtime.status === 'online' &&
!watch.known.has(runtime.id),
)
: undefined,
[watch, runtimes],
);
const waiting = open && watch !== undefined && !connected;
useEffect(() => {
if (!waiting) return;
const timer = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(timer);
}, [waiting]);
const commands = useMemo(() => {
if (!join) return undefined;
try {
return joinCommands(address, join);
} catch {
return undefined;
}
}, [address, join]);
const generate = async () => {
setBusy(true);
setError(undefined);
try {
if (!safeHost(address)) throw new Error(t('collab.runtime.badAddress'));
setJoin(await onCreateJoinToken());
setWatch({
at: Date.now(),
known: onlineIds(runtimes),
});
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
};
const submitExisting = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!onConnectExisting) return;
const data = new FormData(event.currentTarget);
setBusy(true);
setError(undefined);
const known = onlineIds(runtimes);
try {
const ok = await onConnectExisting({
remoteUrl: String(data.get('remoteUrl')),
remoteToken: String(data.get('remoteToken')),
remoteCwd: String(data.get('remoteCwd')),
serverUrl: address,
provider: data.get('provider') === 'codex' ? 'codex' : 'qwen',
allowHttp: data.get('allowHttp') === 'on',
});
if (ok) setWatch({ at: Date.now(), known });
} finally {
setBusy(false);
}
};
const minutesLeft = join
? Math.max(0, Math.ceil((join.expiresAt - now) / 60_000))
: 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t('collab.runtime.addTitle')}</DialogTitle>
<DialogDescription>
{t('collab.runtime.addDescription')}
</DialogDescription>
</DialogHeader>
{connected ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 rounded-md border border-[var(--status-done-fg)]/30 bg-[var(--status-done-bg)] p-3">
<CheckCircle2
aria-hidden="true"
className="size-5 shrink-0 text-[var(--status-done-fg)]"
/>
<div className="min-w-0">
<p className="font-medium">
{t('collab.runtime.connected', { name: connected.label })}
</p>
<p className="text-xs text-muted-foreground">
{t('collab.runtime.offers', {
programs: (connected.providers?.length
? connected.providers
: [connected.provider]
).join(', '),
})}
</p>
</div>
</div>
</div>
) : (
<>
<div
role="tablist"
className="flex gap-4 border-b border-border text-sm"
>
{(['command', 'existing'] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={method === value}
onClick={() => setMethod(value)}
className={`-mb-px border-b-2 pb-2 ${
method === value
? 'border-foreground font-medium'
: 'border-transparent text-muted-foreground'
}`}
>
{value === 'command'
? t('collab.runtime.methodCommand')
: t('collab.runtime.methodExisting')}
</button>
))}
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">{t('collab.runtime.address')}</span>
<Input
value={address}
onChange={(event) => setAddress(event.target.value)}
disabled={join !== undefined}
/>
<span className="text-xs text-muted-foreground">
{LOOPBACK.has(safeHost(address))
? t('collab.runtime.loopbackHint')
: t('collab.runtime.addressHint')}
</span>
</label>
{method === 'command' ? (
join && commands ? (
<div className="flex flex-col gap-3 text-sm">
<p className="font-medium">{t('collab.runtime.runThis')}</p>
<CommandLine text={commands.qwen} />
<p className="text-xs text-muted-foreground">
{t('collab.runtime.noQwen')}
</p>
<CommandLine text={commands.npx} />
<p className="text-xs text-muted-foreground">
{t('collab.runtime.expires', { minutes: minutesLeft })}
{commands.insecure && ` ${t('collab.runtime.httpHint')}`}
</p>
<div className="flex items-center gap-3 rounded-md border border-dashed border-border p-3">
<LoaderCircle
aria-hidden="true"
className="size-4 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none"
/>
<div>
<p className="font-medium">
{t('collab.runtime.waiting')}
</p>
<p className="text-xs text-muted-foreground">
{t('collab.runtime.waitingFor', {
seconds: Math.max(
0,
Math.floor((now - (watch?.at ?? now)) / 1_000),
),
})}
</p>
</div>
</div>
</div>
) : null
) : (
<form
id="connect-existing-runtime"
className="flex flex-col gap-3 text-sm"
onSubmit={(event) => void submitExisting(event)}
>
<label className="flex flex-col gap-1.5">
{t('collab.runtime.remoteUrl')}
<Input
name="remoteUrl"
type="url"
required
placeholder="http://192.168.1.20:4170"
/>
</label>
<label className="flex flex-col gap-1.5">
{t('collab.runtime.remoteToken')}
<Input
name="remoteToken"
type="password"
autoComplete="off"
required
/>
</label>
<label className="flex flex-col gap-1.5">
{t('collab.runtime.remoteCwd')}
<Input
name="remoteCwd"
required
placeholder="/home/me/project"
/>
</label>
<label className="flex flex-col gap-1.5">
{t('collab.runtime.program')}
<select
name="provider"
className="h-9 rounded-md border border-input bg-transparent px-2"
>
<option value="qwen">Qwen Code</option>
<option value="codex">Codex</option>
</select>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" name="allowHttp" />
{t('collab.runtime.allowHttp')}
</label>
</form>
)}
</>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
{connected ? (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('collab.runtime.done')}
</Button>
{onCreateAgentOn && (
<Button onClick={() => onCreateAgentOn(connected.id)}>
{t('collab.runtime.createAgentOn', { name: connected.label })}
</Button>
)}
</>
) : method === 'command' ? (
join ? (
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('collab.runtime.closeKeepLink')}
</Button>
) : (
<Button disabled={busy} onClick={() => void generate()}>
{t('collab.runtime.generate')}
</Button>
)
) : (
<Button
type="submit"
form="connect-existing-runtime"
disabled={busy || !onConnectExisting}
>
{t('collab.runtime.connect')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function safeHost(address: string): string {
try {
return new URL(address).hostname;
} catch {
return '';
}
}

View file

@ -1,272 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef, useState } from 'react';
import { CopyIcon } from 'lucide-react';
import { useI18n } from '../../i18n';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
/** What the daemon returns for a new share; the secret is shown once. */
export interface AgentShare {
endpoint: string;
workspaceId: string;
callerId: string;
agentId: string;
secret: string;
scope: 'analysis' | 'full';
expiresAt?: number;
}
export interface AgentShareSummary {
callerId: string;
scope: 'analysis' | 'full';
createdAt: number;
expiresAt?: number;
}
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
/** The daemon builds the endpoint from the address this page was opened on. */
function isLoopback(endpoint: string): boolean {
try {
return LOOPBACK.has(new URL(endpoint).hostname);
} catch {
return false;
}
}
function curlFor(share: AgentShare): string {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'SendMessage',
params: {
message: {
role: 'ROLE_USER',
messageId: 'hello-1',
parts: [{ text: 'Hello! What can you help with?' }],
},
},
});
return [
`curl -s ${share.endpoint}`,
`-H 'A2A-Version: 1.0'`,
`-H 'Authorization: Bearer ${share.secret}'`,
`-H 'x-qwen-workspace-id: ${share.workspaceId}'`,
`-H 'x-qwen-caller-id: ${share.callerId}'`,
`-H 'x-qwen-agent-id: ${share.agentId}'`,
`-H 'content-type: application/json'`,
`-d '${body}'`,
].join(' ');
}
function Copyable({ label, text }: { label: string; text: string }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
return (
<div className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">{label}</span>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1.5 pr-1.5 pl-3">
<code className="min-w-0 flex-1 truncate font-mono text-xs">
{text}
</code>
<Button
size="sm"
variant="outline"
onClick={() =>
void writeClipboardText(text).then(
() => setCopied(true),
(error: unknown) => warnClipboardWriteFailure(error),
)
}
>
<CopyIcon aria-hidden="true" />
{copied ? t('collab.runtime.copied') : t('collab.runtime.copy')}
</Button>
</div>
</div>
);
}
/**
* Share one agent with a caller outside this workspace over A2A. The agent
* keeps running here; the caller gets an endpoint, a token shown once and a
* one-line test. Each share is its own grant, so revoking one leaves the
* others working.
*/
export function ShareAgentDialog({
agentName,
open,
onOpenChange,
onCreate,
onList,
onRevoke,
}: {
agentName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (scope: 'analysis' | 'full') => Promise<AgentShare>;
onList: () => Promise<AgentShareSummary[]>;
onRevoke: (callerId: string) => Promise<unknown>;
}) {
const { t } = useI18n();
const [scope, setScope] = useState<'analysis' | 'full'>('analysis');
const [share, setShare] = useState<AgentShare>();
const [shares, setShares] = useState<AgentShareSummary[]>([]);
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
// Read on open only; the caller's handler identity may change every render.
const listRef = useRef(onList);
listRef.current = onList;
useEffect(() => {
if (!open) return;
setShare(undefined);
setError(undefined);
void listRef.current().then(setShares, () => setShares([]));
}, [open]);
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
setError(undefined);
try {
await action();
setShares(await listRef.current());
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{t('collab.share.title', { name: agentName })}
</DialogTitle>
<DialogDescription>{t('collab.share.description')}</DialogDescription>
</DialogHeader>
{share ? (
<div className="flex flex-col gap-3">
<Copyable
label={t('collab.share.endpoint')}
text={share.endpoint}
/>
<Copyable label={t('collab.share.token')} text={share.secret} />
<Copyable label={t('collab.share.try')} text={curlFor(share)} />
<p className="text-xs text-muted-foreground">
{t('collab.share.once')}
</p>
{isLoopback(share.endpoint) && (
<p role="alert" className="text-xs text-destructive">
{t('collab.share.loopback')}
</p>
)}
</div>
) : (
<div
role="radiogroup"
aria-label={t('collab.share.scope')}
className="flex flex-col gap-2 text-sm"
>
{(['analysis', 'full'] as const).map((value) => (
<label
key={value}
className="flex items-start gap-2 rounded-md border border-border p-3"
>
<input
type="radio"
name="share-scope"
checked={scope === value}
onChange={() => setScope(value)}
/>
<span>
<span className="block font-medium">
{t(`collab.share.scope.${value}`)}
</span>
<span className="block text-xs text-muted-foreground">
{t(`collab.share.scope.${value}Hint`)}
</span>
</span>
</label>
))}
</div>
)}
{shares.length > 0 && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-xs text-muted-foreground">
{t('collab.share.active', { count: shares.length })}
</span>
{shares.map((entry) => (
<div
key={entry.callerId}
className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted"
>
<span className="min-w-0 flex-1 truncate">
{t(`collab.share.scope.${entry.scope}`)}
<span className="ml-2 text-xs text-muted-foreground">
{entry.expiresAt
? t('collab.share.until', {
date: new Date(entry.expiresAt).toLocaleDateString(),
})
: ''}
</span>
</span>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => void run(() => onRevoke(entry.callerId))}
>
{t('collab.share.revoke')}
</Button>
</div>
))}
</div>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
{share ? (
<Button onClick={() => onOpenChange(false)}>
{t('collab.runtime.done')}
</Button>
) : (
<Button
disabled={busy}
onClick={() =>
void run(async () => setShare(await onCreate(scope)))
}
>
{t('collab.share.create')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -116,17 +116,6 @@ export function useAgentChatEntry({
.map((agent) => ({
id: agent.id,
label: agent.name,
// "Program · Runtime", as on the Agents page.
subtitle: `${
agent.execution?.mode === 'managed-host' &&
agent.execution.provider === 'codex'
? 'Codex'
: 'Qwen Code'
} · ${
agent.runtime.kind === 'local'
? t('collab.agent.thisComputer')
: agent.runtime.label
}`,
description: t(`collab.agentStatus.${agent.status}`),
...(agent.color ? { iconColor: agent.color } : {}),
insertText: `@${agent.name} `,

View file

@ -16,7 +16,7 @@ node ../../node_modules/@playwright/test/cli.js test client/e2e/web-shell.mesh-s
一条连续流程检查:
- 从聊天输入框发送 `@stream-worker`,只发送一次。
- 绑定 Host 离线时明确显示主机名称与等待恢复;启动后看到两段思考内容的累计增长,刷新仍可读。
- 排队时显示排队等待;启动后看到两段思考内容的累计增长,刷新仍可读。
- run 仍 running、正式结果未产生时,正文先后显示两段累计文本。
- 刷新页面后仍显示中间正文,不重新提交任务。
- 超过 20 秒没有遥测时显示连接待确认,已显示的正文不丢失。
@ -26,9 +26,9 @@ node ../../node_modules/@playwright/test/cli.js test client/e2e/web-shell.mesh-s
为专注正文恢复,测试通过浏览器初始化脚本选中协作对话,每次刷新都指定同一个对话;不把它算作“侧边栏选择记忆”的验收。
## 2. 真实 Host + 浏览器(显式启用,会使用模型额度)
## 2. 真实 Agent + 浏览器(显式启用,会使用模型额度)
先启动这条分支的协作 daemon,并确认目标工作区中已有启用的 Agent、它对应的 Host 在线且执行程序已登录。不要用日常忙碌中的 Agent;建议使用专门的 demo Agent。脚本不会安装 Host、创建 Agent、切换权限、登录模型或改写工程文件。
先启动这条分支的协作 daemon,并确认目标工作区中已有启用的 Agent、模型已登录。不要用日常忙碌中的 Agent;建议使用专门的 demo Agent。脚本不会创建 Agent、切换权限、登录模型或改写工程文件。
例如在仓库根目录启动 daemon:
@ -54,19 +54,7 @@ node ../../node_modules/@playwright/test/cli.js test client/e2e/web-shell.mesh-s
此模式通过真实 API 新建一个 `E2E live stream ...` 对话,随后在真实聊天框中发送消息。必须在结束前看到至少两次增长的正文;第一次增长后刷新验证恢复;完成后从真实 API 确认一条正式结果,并再次刷新确认页面没有重复。不会把“只出现最终结果”算通过。
本地 Qwen 必须调用 `thread_review` 才算交回验收,因此测试允许该协作工具,仅禁止文件修改、命令和联网;不要把提示词改成笼统的“禁止所有工具”。Codex Host 的最终结果由 Host 回传,两条路径不能混淆。
### Codex 已登录但任务仍排队
Codex App/CLI 登录状态不等于 Agent Host 接单进程在线。检查执行主机的心跳和接单进程;不要为清队列重新发消息或重建 Agent。已有注册凭证时,在原执行目录恢复 Host(协调端地址必须与当前 daemon 一致):
```bash
QWEN_CODE_ENABLE_AGENT_COLLABORATION=1 npm run dev -- serve --no-web --hostname 127.0.0.1 --port 0 --no-open --agent-host-server http://127.0.0.1:4171 --agent-host-provider codex --agent-host-workspace-id '<原工作区 ID>'
```
注册凭证按协调端 URL、工作区 ID 和执行目录保存。换端口不会自动沿用旧注册;确认是同一协调端数据后才能迁移原凭证,不能把凭证发给未经确认的新服务。此进程必须保持运行,当前并非系统自启动服务。
Codex 流式接收思考摘要时会显式请求 `summary: auto`;是否实际返回摘要仍由模型决定。没有摘要时只展示真实阶段和回复,不能虚构思考内容。`thread_post` / `thread_review` 的正式消息仍在工具提交时出现,不等于工具参数已逐字流式展示。
本地 Qwen 必须调用 `thread_review` 才算交回验收,因此测试允许该协作工具,仅禁止文件修改、命令和联网;不要把提示词改成笼统的“禁止所有工具”。
为保留失败现场,测试对话不会删除;报告附有其 ID。测试 finally 仅请求取消本次新建对话里仍活跃的 run,不触碰其他任务。进程被强杀时 finally 无法保证执行,请根据报告中的对话 ID 手动检查。
@ -80,12 +68,8 @@ node ../../node_modules/@playwright/test/cli.js show-report client/e2e/playwrigh
成功流程也保存 growing/completed 截图;真实模式附 `created-thread` 和 `live-observations`(每次正文增长的耗时与字符数)。现有配置会在失败时保留截图、视频。需要完整首次 trace 时在运行命令追加 `--trace on`。这些产物可能包含对话信息,不要直接上传公开 PR。
没有设置真实模式的两个环境变量时,真实模式明确显示 skipped,不代表已验收。真实模型可能在首段前等待较久,或一次性返回太短文本;若无法观测到两次增长,此项必须失败而不是放宽成“最终能回复”。跨机器 Host、Qwen 与 Codex 各需使用对应的真实 Agent 分别执行;某一条跑通不代表全部供应方通过。
没有设置真实模式的两个环境变量时,真实模式明确显示 skipped,不代表已验收。真实模型可能在首段前等待较久,或一次性返回太短文本;若无法观测到两次增长,此项必须失败而不是放宽成“最终能回复”。
本次记录(2026-09-14):默认页面回归 `1 passed (13.5s)`,真实 Host 模式未执行。首次运行因状态文字旁包含取消按钮导致精确文本定位失败,修正为定位 Agent activity 面板后重跑通过;没有放宽正文增长或去重断言。
本次记录(2026-09-14):默认页面回归 `1 passed (13.5s)`,真实模式未执行。首次运行因状态文字旁包含取消按钮导致精确文本定位失败,修正为定位 Agent activity 面板后重跑通过;没有放宽正文增长或去重断言。
后续补充(同日):加入排队/离线与思考增量断言后,默认回归 `1 passed (13.0s)`;真实本地 Qwen 模式 `1 passed (1.0m)`。同步观察到 31 次思考增长、17 次正文增长,正式结果为一条。真实运行的思考采样和截图操作也补入本脚本;未据此宣称远程 Host 或 Codex 思考摘要已验收。
Codex Host 补验(同日):恢复原 Host 接单进程和当前协调端连接后,原积压任务完成,未重发消息。唯一一条长回复通过真实侧栏进入并在聊天框发送:约 0.35 秒开始执行,15.517 秒首次正文 94 字符,结束前观察到 25 次正文增长,27.886 秒达到 2478 字符,28.159 秒完成;主聊天在结束前可见,最终正式结果一条。13.539 秒出现思考阶段,但即使请求 `summary: auto`,本次思考摘要字符数仍为 0,不算思考正文流式通过。
本次命名 Playwright 用例因开发服务器 `main.tsx` 资源加载失败中断,不算通过;以上为复用同一已创建对话、独立 Node + Playwright 页面操作与只读采样的替代验收,仅发送一次模型请求,没有新增第二条对话。未执行构建或本地 CI。
后续补充(同日):加入排队与思考增量断言后,默认回归 `1 passed (13.0s)`;真实本地 Qwen 模式 `1 passed (1.0m)`。同步观察到 31 次思考增长、17 次正文增长,正式结果为一条。真实运行的思考采样和截图操作也补入本脚本。

View file

@ -53,8 +53,7 @@ test('mesh shows growing replies before completion, survives reload, and replace
id: 'ag_stream',
name: 'stream-worker',
enabled: true,
status: 'offline',
runtime: { label: 'Demo-Host', status: 'offline' },
status: 'idle',
};
let sent = 0;
let releaseReply!: () => void;
@ -139,7 +138,7 @@ test('mesh shows growing replies before completion, survives reload, and replace
await expect(
page
.getByRole('status')
.filter({ hasText: 'stream-worker 所在的 Runtime 离线' }),
.filter({ hasText: 'stream-worker 排队中,空出来就开始' }),
).toBeVisible();
const activity = page.getByRole('region', {
name: '团队',
@ -150,10 +149,8 @@ test('mesh shows growing replies before completion, survives reload, and replace
await expect(
page.getByRole('tab', { name: '团队', exact: true }),
).toBeVisible();
await expect(activity).toContainText('Demo-Host 离线');
await expect(activity).toContainText('消息已接收,排队等待启动');
const run = thread.runs[0];
agent.status = 'idle';
agent.runtime.status = 'online';
run.status = 'running';
const initialProgress = run.progress!;
run.progress = undefined;
@ -250,7 +247,7 @@ test('mesh shows growing replies before completion, survives reload, and replace
await page.screenshot({ path: info.outputPath('02-completed.png') });
});
test('mesh real Host streams into the browser @mesh-live', async ({
test('mesh real agent streams into the browser @mesh-live', async ({
page,
request,
}, info) => {
@ -267,7 +264,7 @@ test('mesh real Host streams into the browser @mesh-live', async ({
const agentsResponse = await request.get(`${prefix}/agents`);
expect(
agentsResponse.ok(),
'Start a collaboration-enabled loopback daemon and connect an online Host first',
'Start a collaboration-enabled loopback daemon first',
).toBeTruthy();
const { agents } = await agentsResponse.json();
expect(

View file

@ -491,8 +491,6 @@ const EN: Messages = {
'collab.elapsed.minutes': (v) => `${v?.minutes ?? 0}m ${v?.seconds ?? 0}s`,
'collab.run.queued': (v) =>
`${v?.agent} is queued and starts when it is free`,
'collab.run.hostOffline': (v) =>
`${v?.agent} is waiting for its runtime to come back online`,
'collab.run.starting': (v) => `Starting ${v?.agent}…`,
'collab.run.stopping': (v) => `Stopping ${v?.agent}…`,
'collab.run.thinking': (v) => `${v?.agent} is thinking · ${v?.elapsed}`,
@ -506,71 +504,12 @@ const EN: Messages = {
'collab.run.stalled': (v) =>
`${v?.agent} has shown no progress for ${v?.elapsed}. It may be stuck.`,
'collab.run.stop': 'Stop',
'collab.runtime.addTitle': 'Add a runtime',
'collab.runtime.addDescription':
'Let another computer run agents for this workspace. It can offer Qwen Code and, if installed, Codex.',
'collab.runtime.methodCommand': 'Run one command there',
'collab.runtime.methodExisting': 'I have its address and token',
'collab.runtime.address':
'This computer’s address, as the other computer sees it',
'collab.runtime.addressHint':
'The other computer connects out to this address, so it only has to be reachable from there.',
'collab.runtime.loopbackHint':
'localhost only works on this computer. Use its network address (for example http://192.168.1.8:4170) so the other computer can reach it.',
'collab.runtime.generate': 'Create join command',
'collab.runtime.runThis':
'On the other computer, run this in the project folder:',
'collab.runtime.noQwen': 'Qwen Code not installed there? This works too:',
'collab.runtime.copy': 'Copy',
'collab.runtime.copied': 'Copied',
'collab.runtime.expires': (v) =>
`The link works once and expires in ${v?.minutes ?? 0} min.`,
'collab.runtime.httpHint':
'It uses plain HTTP, so only use it on a network you trust.',
'collab.runtime.waiting': 'Waiting for it to connect…',
'collab.runtime.waitingFor': (v) =>
`${v?.seconds ?? 0}s so far. This updates by itself when it connects.`,
'collab.runtime.closeKeepLink': 'Close (the link keeps working)',
'collab.runtime.connected': (v) => `${v?.name} is connected`,
'collab.runtime.offers': (v) => `Offers ${v?.programs}`,
'collab.runtime.done': 'Done',
'collab.runtime.createAgentOn': (v) => `Create an agent on ${v?.name}`,
'collab.runtime.remoteUrl': 'Its Qwen Code address',
'collab.runtime.remoteToken': 'Its access token',
'collab.runtime.remoteCwd': 'Project folder on that computer',
'collab.runtime.program': 'Program',
'collab.runtime.allowHttp':
'Allow plain HTTP (trusted networks only; token and tasks are unencrypted)',
'collab.runtime.connect': 'Connect',
'collab.share.title': (v) => `Share ${v?.name}`,
'collab.share.description':
'Anyone with the token can send this agent work over A2A. It keeps running on this computer, in this workspace.',
'collab.share.scope': 'What they may ask for',
'collab.share.scope.analysis': 'Read-only: look at the code and answer',
'collab.share.scope.analysisHint': 'The agent cannot change files for them.',
'collab.share.scope.full': 'Everything this agent can do',
'collab.share.scope.fullHint': 'Same as you asking it directly.',
'collab.share.create': 'Create link',
'collab.share.endpoint': 'A2A endpoint',
'collab.share.token': 'Token',
'collab.share.try': 'Try it',
'collab.share.once':
'The token is shown only now. It expires in 7 days; revoke it here any time.',
'collab.share.loopback':
'This address only works on this computer. Open Web Shell through an address the caller can reach, then create the share.',
'collab.share.active': (v) => `Active shares (${v?.count ?? 0})`,
'collab.share.until': (v) => `until ${v?.date}`,
'collab.share.revoke': 'Revoke',
'collab.agent.share': 'Share',
'collab.tabs.agents': 'Agents',
'collab.tabs.tasks': 'Conversations',
'collab.tabs.runtime': 'Runtimes',
'collab.tabs.agentsHint':
'Agents are teammates you create. Each runs on a runtime with one program. @ an agent in any conversation to bring it in; it can bring in others.',
'Agents are teammates you create. @ an agent in any conversation to bring it in; it can bring in others.',
'collab.tabs.tasksHint':
'Conversations where agents are working, grouped by what they need from you.',
'collab.tabs.runtimeHint':
'Runtimes are the computers agents run on: this one, and any that joined with a link.',
'collab.agent.new': 'New agent',
'collab.agent.roles': 'Role templates',
'collab.agent.mentionIt': '@ Mention',
@ -596,13 +535,6 @@ const EN: Messages = {
'collab.agent.concurrency': 'Conversations at once',
'collab.agent.concurrencyInvalid':
'Conversations at once must be between 1 and 8.',
'collab.agent.runsOn': 'Runs on',
'collab.agent.thisComputer': 'This computer',
'collab.agent.cwdUnknown': 'Folder not reported yet',
'collab.agent.runsOnHint':
'A joined runtime works in its own folder, shown under its name. Files here are not copied to it.',
'collab.agent.programLocal': 'Runs only on a joined runtime',
'collab.agent.programMissing': 'Not found on this runtime',
'collab.agent.afterCreate':
'It starts working when you mention it in a conversation.',
'collab.thread.new': 'New conversation',
@ -613,15 +545,6 @@ const EN: Messages = {
'Say what you need and who should take it. The conversation opens once it is created.',
'collab.agent.empty':
'No agents yet. Create one, then @ it in any conversation.',
'collab.runtime.localNote':
'This computer. Its agents run in this workspace with Qwen Code.',
'collab.runtime.remoteNote':
'Another computer that joined this workspace. Agents assigned to it run there.',
'collab.runtime.programs': 'Programs',
'collab.runtime.empty': 'No runtime yet.',
'collab.runtime.emptyHint':
'Add a runtime to let another computer run agents, then create an agent on it.',
'collab.runtime.badAddress': 'Enter an address like http://192.168.1.8:4170.',
'collab.run.keepWaiting': 'Keep waiting',
'collab.run.timedOut': (v) =>
`${v?.agent} was stopped after 15 minutes without progress`,
@ -674,7 +597,6 @@ const EN: Messages = {
'collab.team.history': (v) => `Past runs (${v?.count ?? 0})`,
'collab.member.idle': 'Idle',
'collab.member.queued': 'Queued',
'collab.member.offline': 'Runtime offline',
'collab.member.starting': 'Starting',
'collab.member.thinking': 'Thinking',
'collab.member.responding': 'Replying',
@ -4727,8 +4649,6 @@ const ZH: Messages = {
'collab.elapsed.minutes': (v) =>
`${v?.minutes ?? 0} 分 ${v?.seconds ?? 0} 秒`,
'collab.run.queued': (v) => `${v?.agent} 排队中,空出来就开始`,
'collab.run.hostOffline': (v) =>
`${v?.agent} 所在的 Runtime 离线,回来后自动继续`,
'collab.run.starting': (v) => `正在唤起 ${v?.agent}…`,
'collab.run.stopping': (v) => `正在停止 ${v?.agent}…`,
'collab.run.thinking': (v) => `${v?.agent} 正在思考,${v?.elapsed}`,
@ -4742,65 +4662,11 @@ const ZH: Messages = {
'collab.run.stalled': (v) =>
`${v?.agent} 已经 ${v?.elapsed} 没有进展,可能卡住了`,
'collab.run.stop': '停止',
'collab.runtime.addTitle': '添加 Runtime',
'collab.runtime.addDescription':
'让另一台电脑为这个工作区运行 Agent。它可以提供 Qwen Code,装了的话还有 Codex。',
'collab.runtime.methodCommand': '在那台电脑上运行一行命令',
'collab.runtime.methodExisting': '我已有地址和令牌',
'collab.runtime.address': '这台电脑的地址(从那台电脑看过来)',
'collab.runtime.addressHint':
'那台电脑会主动连到这个地址,只要从那边能访问到就行。',
'collab.runtime.loopbackHint':
'localhost 只在这台电脑上有效。请换成它的局域网地址(例如 http://192.168.1.8:4170),另一台电脑才能连上。',
'collab.runtime.generate': '生成加入命令',
'collab.runtime.runThis': '在那台电脑的项目目录里运行:',
'collab.runtime.noQwen': '那台电脑没装 Qwen Code?用这条也行:',
'collab.runtime.copy': '复制',
'collab.runtime.copied': '已复制',
'collab.runtime.expires': (v) =>
`链接只能用一次,${v?.minutes ?? 0} 分钟后失效。`,
'collab.runtime.httpHint': '使用的是 HTTP 明文,只在可信网络里用。',
'collab.runtime.waiting': '正在等待连接…',
'collab.runtime.waitingFor': (v) =>
`已等待 ${v?.seconds ?? 0} 秒。连上后这里会自动更新。`,
'collab.runtime.closeKeepLink': '关闭(链接仍然有效)',
'collab.runtime.connected': (v) => `${v?.name} 已连接`,
'collab.runtime.offers': (v) => `提供 ${v?.programs}`,
'collab.runtime.done': '完成',
'collab.runtime.createAgentOn': (v) => `在 ${v?.name} 上新建 Agent`,
'collab.runtime.remoteUrl': '它的 Qwen Code 地址',
'collab.runtime.remoteToken': '它的访问令牌',
'collab.runtime.remoteCwd': '那台电脑上的项目目录',
'collab.runtime.program': '执行程序',
'collab.runtime.allowHttp': '允许 HTTP 明文(仅可信网络,令牌和任务不加密)',
'collab.runtime.connect': '连接',
'collab.share.title': (v) => `分享 ${v?.name}`,
'collab.share.description':
'拿到令牌的人可以通过 A2A 给这个 Agent 派活。它仍然运行在这台电脑上、这个工作区里。',
'collab.share.scope': '对方能让它做什么',
'collab.share.scope.analysis': '只读:看代码、回答问题',
'collab.share.scope.analysisHint': 'Agent 不会替对方改文件。',
'collab.share.scope.full': '这个 Agent 能做的一切',
'collab.share.scope.fullHint': '和你直接找它一样。',
'collab.share.create': '生成分享',
'collab.share.endpoint': 'A2A 地址',
'collab.share.token': '令牌',
'collab.share.try': '试一下',
'collab.share.once': '令牌只显示这一次,7 天后失效,随时可以在这里撤销。',
'collab.share.loopback':
'这个地址只在本机可用。请用别人能访问到的地址打开 Web Shell,再生成分享。',
'collab.share.active': (v) => `已分享(${v?.count ?? 0})`,
'collab.share.until': (v) => `有效至 ${v?.date}`,
'collab.share.revoke': '撤销',
'collab.agent.share': '分享',
'collab.tabs.agents': 'Agent',
'collab.tabs.tasks': '协作对话',
'collab.tabs.runtime': 'Runtime',
'collab.tabs.agentsHint':
'Agent 是你新建的队友,每个都跑在某个 Runtime 上、用一个程序。在任何对话里 @ 它就能叫它来,它也能再叫别的 Agent。',
'Agent 是你新建的队友。在任何对话里 @ 它就能叫它来,它也能再叫别的 Agent。',
'collab.tabs.tasksHint': 'Agent 正在工作的对话,按需要你做什么来分组。',
'collab.tabs.runtimeHint':
'Runtime 是运行 Agent 的电脑:这台电脑,以及用链接加入的其他电脑。',
'collab.agent.new': '新建 Agent',
'collab.agent.roles': '角色模板',
'collab.agent.mentionIt': '@ 它',
@ -4822,13 +4688,6 @@ const ZH: Messages = {
'collab.agent.description': '其他 Agent 什么时候该找它(可选)',
'collab.agent.concurrency': '同时处理的对话数',
'collab.agent.concurrencyInvalid': '同时处理的对话数必须在 1 到 8 之间。',
'collab.agent.runsOn': '运行在',
'collab.agent.thisComputer': '这台电脑',
'collab.agent.cwdUnknown': '尚未上报目录',
'collab.agent.runsOnHint':
'加入的 Runtime 在它自己的目录里工作(显示在名字下方),这里的文件不会复制过去。',
'collab.agent.programLocal': '只能在加入的 Runtime 上运行',
'collab.agent.programMissing': '这个 Runtime 上没有检测到',
'collab.agent.afterCreate': '在对话里 @ 它,它就开始工作。',
'collab.thread.new': '新建协作对话',
'collab.noWorkspace': '先打开一个工作区,才能和 Agent 协作。',
@ -4836,15 +4695,6 @@ const ZH: Messages = {
'collab.thread.loading': '正在加载对话…',
'collab.thread.newHint': '写下要做什么、交给谁。创建后会打开这个对话。',
'collab.agent.empty': '还没有 Agent。新建一个,然后在任意对话里 @ 它。',
'collab.runtime.localNote':
'这台电脑。它上面的 Agent 用 Qwen Code 在这个工作区里运行。',
'collab.runtime.remoteNote':
'加入了这个工作区的另一台电脑。分配给它的 Agent 在那边运行。',
'collab.runtime.programs': '程序',
'collab.runtime.empty': '还没有 Runtime。',
'collab.runtime.emptyHint':
'添加一个 Runtime,让另一台电脑运行 Agent,再在它上面新建 Agent。',
'collab.runtime.badAddress': '请填写类似 http://192.168.1.8:4170 的地址。',
'collab.run.keepWaiting': '再等等',
'collab.run.timedOut': (v) => `${v?.agent} 15 分钟没有进展,已停止`,
'collab.run.failed': (v) => `${v?.agent} 出错停止了`,
@ -4891,7 +4741,6 @@ const ZH: Messages = {
'collab.team.history': (v) => `历史运行(${v?.count ?? 0})`,
'collab.member.idle': '空闲',
'collab.member.queued': '排队中',
'collab.member.offline': 'Runtime 离线',
'collab.member.starting': '正在唤起',
'collab.member.thinking': '正在思考',
'collab.member.responding': '正在回复',

View file

@ -190,7 +190,6 @@ export default defineConfig(({ command }) => ({
'/session': daemonProxy,
'/permission': daemonProxy,
'^/workspaces/[^/]+/agent(?:/|$)': daemonProxy,
'/agent-hosts': daemonProxy,
[QUALIFIED_VOICE_STREAM_PROXY]: { ...daemonProxy, ws: true },
[QUALIFIED_ACP_WS_PROXY]: { ...daemonProxy, ws: true },
'/workspace': daemonProxy,

30
pnpm-lock.yaml generated
View file

@ -506,9 +506,6 @@ importers:
packages/cli:
dependencies:
'@a2a-js/sdk':
specifier: ^1.1.0
version: 1.1.0(@grpc/grpc-js@1.14.4)(express@5.2.1(supports-color@7.2.0))
'@agentclientprotocol/sdk':
specifier: ^0.14.1
version: 0.14.1(zod@3.25.76)
@ -1587,21 +1584,6 @@ importers:
packages:
'@a2a-js/sdk@1.1.0':
resolution: {integrity: sha512-/Mhzw9C6VW7pFbY2Rq0pnrjT0Fy9PV0c46A7Gx1ppRlYq2u/6OV/bNRIoVBKChb8UZ8bE0WzzCc0pIr2CdmG+w==}
engines: {node: '>=20'}
peerDependencies:
'@bufbuild/protobuf': ^2.10.2
'@grpc/grpc-js': ^1.11.0
express: ^4.21.2 || ^5.1.0
peerDependenciesMeta:
'@bufbuild/protobuf':
optional: true
'@grpc/grpc-js':
optional: true
express:
optional: true
'@agentclientprotocol/sdk@0.14.1':
resolution: {integrity: sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==}
peerDependencies:
@ -7418,9 +7400,6 @@ packages:
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
jose@6.2.12:
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -10370,13 +10349,6 @@ packages:
snapshots:
'@a2a-js/sdk@1.1.0(@grpc/grpc-js@1.14.4)(express@5.2.1(supports-color@7.2.0))':
dependencies:
jose: 6.2.12
optionalDependencies:
'@grpc/grpc-js': 1.14.4
express: 5.2.1(supports-color@7.2.0)
'@agentclientprotocol/sdk@0.14.1(zod@3.25.76)':
dependencies:
zod: 3.25.76
@ -16932,8 +16904,6 @@ snapshots:
jose@6.1.3: {}
jose@6.2.12: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}