Просмотр исходного кода

feat(bridge): 完善 PaaS 任务恢复与通用客户端交互

为用户通过 OpenCode 使用多类 PaaS 智能体补齐任务控制、文件传输和断线恢复,避免重复执行及将客户端结束误报为任务完成。

- 提供通用智能体入口、任务历史与跨会话找回、输入上传和鉴权产物下载,以及桥接安装清单
- 持久化请求键与观察游标,核对取消结果,增加审批截止提醒和短时预检查缓存
- 在 TUI 展示真实执行状态、科研状态与计量完整性,仅向支持的执行器传递客户端费用预算
- 增加四类固定输入验收、独立数据与引用检查,并提供独立软件测试脚本和运行说明
- 客户端 74 项与界面 7 项测试通过,两包类型检查通过;真实 HTTP 联调和一轮 Qwen 科研样例完成,软件容器验收尚未执行
邵勃源 4 дней назад
Родитель
Сommit
e35ab52873
53 измененных файлов с 3697 добавлено и 450 удалено
  1. 95 0
      .opencode/README.agentpaas.md
  2. 4 126
      .opencode/README.md
  3. 37 0
      .opencode/agent/paas.md
  4. 10 1
      .opencode/agent/research-67-action-reasoner.md
  5. 10 1
      .opencode/agent/research-67-claim-verifier.md
  6. 10 1
      .opencode/agent/research-67-data-verifier.md
  7. 10 1
      .opencode/agent/research-67-exp-executor.md
  8. 10 1
      .opencode/agent/research-67-exp-planner.md
  9. 10 1
      .opencode/agent/research-67-hypothesis-builder.md
  10. 10 1
      .opencode/agent/research-67-idea-analyst.md
  11. 10 1
      .opencode/agent/research-67-lit-searcher.md
  12. 10 1
      .opencode/agent/research-67-ontology-auditor.md
  13. 10 1
      .opencode/agent/research-67-ontology-enricher.md
  14. 10 1
      .opencode/agent/research-67-ontology-experiment-planner.md
  15. 10 1
      .opencode/agent/research-67-orchestrator.md
  16. 10 1
      .opencode/agent/research-67-paper-reviewer.md
  17. 10 1
      .opencode/agent/research-67-paper-writer.md
  18. 10 1
      .opencode/agent/research-67-pipeline.md
  19. 10 1
      .opencode/agent/research-67-plan-builder.md
  20. 10 1
      .opencode/agent/research-67-result-analyst.md
  21. 10 1
      .opencode/agent/research-67-result-analyzer.md
  22. 10 1
      .opencode/agent/research-67-review-feedback.md
  23. 22 2
      .opencode/agent/research67.md
  24. 39 0
      .opencode/agentpaas-manifest.json
  25. 7 0
      .opencode/command/paas-check.md
  26. 7 0
      .opencode/command/paas-status.md
  27. 7 0
      .opencode/command/paas-tasks.md
  28. 7 0
      .opencode/command/paas.md
  29. 460 0
      .opencode/lib/agentpaas.ts
  30. 60 0
      .opencode/lib/approval-monitor.ts
  31. 147 0
      .opencode/lib/recovery.ts
  32. 24 0
      .opencode/plugin/agentpaas-notify.ts
  33. 530 0
      .opencode/tool/agentpaas.ts
  34. 194 283
      .opencode/tool/agentpaas_run.ts
  35. 73 0
      evals/agentpaas-acceptance/README.md
  36. 122 0
      evals/agentpaas-acceptance/cases.json
  37. 9 0
      evals/agentpaas-acceptance/evidence.md
  38. 39 0
      evals/agentpaas-acceptance/independent-software-check.py
  39. 7 0
      evals/agentpaas-acceptance/measurements.csv
  40. 4 0
      evals/agentpaas-acceptance/policy.txt
  41. 11 0
      evals/agentpaas-acceptance/software-task.md
  42. 192 0
      packages/opencode/test/tool/agentpaas-acceptance.test.ts
  43. 1 0
      packages/opencode/test/tool/agentpaas-admission.test.ts
  44. 175 0
      packages/opencode/test/tool/agentpaas-background.test.ts
  45. 575 0
      packages/opencode/test/tool/agentpaas-client.test.ts
  46. 53 10
      packages/opencode/test/tool/agentpaas-protocol.test.ts
  47. 24 9
      packages/opencode/test/tool/agentpaas-run.test.ts
  48. 42 1
      packages/tui/src/routes/session/index.tsx
  49. 90 0
      packages/tui/src/util/agentpaas.ts
  50. 57 0
      packages/tui/test/util/agentpaas.test.ts
  51. 226 0
      script/accept-agentpaas.ts
  52. 48 0
      script/install-agentpaas.ts
  53. 119 0
      script/lib/agentpaas-acceptance.ts

+ 95 - 0
.opencode/README.agentpaas.md

@@ -0,0 +1,95 @@
+# 在 OpenCode 中使用 PaaS
+
+OpenCode 是用户入口;PaaS 管理智能体、模型、资源、执行记录和服务端工作区。本套桥接适用于本仓库的 OpenCode fork,支持返回带 metadata/attachments 的自定义工具。尚未对其他发行版作兼容性承诺。
+
+## 安装和连接
+
+在本仓库运行,目标可以是任意用户项目,不必从桥接源码目录启动:
+
+```bash
+bun script/install-agentpaas.ts --target /absolute/path/to/project
+```
+
+安装只复制清单中的桥接工具、辅助模块、门面和命令,不修改项目的模型配置或凭证。替换文件前自动备份;重复安装相同内容不写入。用 `--check` 检查安装是否一致。
+
+配置 PaaS 地址及租户凭证:
+
+```bash
+export AGENTPAAS_URL=http://127.0.0.1:8010
+export AGENTPAAS_API_KEY=你的租户APIKey
+```
+
+也可使用 `~/.agentpaas/config.json` 的 `server`、`api_key` 字段;环境变量优先。未指定地址时默认端口为 8000;8010 仅是本地启动示例。不要把凭证提交到 Git。OpenCode 自己的入口模型由用户现有配置选择;执行任务的模型由 PaaS 智能体配置决定,这两份配置不同。
+
+在目标项目中用本 fork 启动 `--agent paas`,或输入 `/paas`。科研专项仍可选择 `research67`。新安装后重新启动 OpenCode,使其加载工具。
+
+## 日常操作
+
+| 用户要做的事 | 客户端操作 |
+| --- | --- |
+| 检查智能体资源 | `/paas-check` → `agentpaas_check`;默认不调用模型,可按需探测模型与知识检索 |
+| 找回跨会话任务 | `/paas-tasks` → `agentpaas_tasks`,刷新本机此账号最近登记的任务,默认最多 20 个 |
+| 查看有哪些智能体 | `agentpaas_agents`:返回实际目录、ID、运行位置和发现状态 |
+| 处理本机 PDF/CSV 等材料 | `agentpaas_upload` 上传明确选择的文件,再将 `file_id` 交给 `agentpaas_run.input_file_ids` |
+| 新建任务或修订方案 | `agentpaas_run`,长任务使用 `background=true`;每回合最多新建一次,默认复用该智能体的远端会话 |
+| 查看之前的任务 | `agentpaas_task` 的 `history`、`status`;`/paas-status` 是查询快捷入口 |
+| 接着某个历史任务修改 | `agentpaas_task` 的 `select` 指定 run_id,再按原任务状态恢复或提交修订;仅查看不切换上下文 |
+| 断线后继续查看 | `agentpaas_task` 的 `watch`,重放原任务事件,不重新执行 |
+| 暂停、恢复或停止 | `pause`、`resume`、`cancel`;恢复使用原 checkpoint |
+| 审批 | 展示材料后用 `approve` 或 `reject`,仅按用户的明确决定操作 |
+| 获取结果文件 | `agentpaas_download`:带鉴权下载并返回本机文件链接 |
+
+同名智能体用目录中的 `agent_id` 精确选择。未完成远端发现时只重试目录查询,绝不自动重试任务提交或控制操作。任务目录返回空列表,不代表执行服务或模型健康;后台 PaaS 和执行服务都必须运行。
+
+上传需用户授权所选路径。客户端先读取 `/files/capabilities`,按服务端限制与客户端 256 MiB 上限中的较小值检查。默认服务端同为 256 MiB,可通过 `AGENTPAAS_FILE_MAX_SIZE_BYTES` 调低;过大的文件在上传前即被拒绝。下载默认放在当前项目 `.agentpaas-artifacts/`,校验可用的大小/摘要并拒绝覆盖文件;重复查看已下载文件时复用已有链接。将该目录加入项目的忽略规则,避免提交研究数据。`work_dir` 始终是服务端可见目录,不能拿客户端路径代替上传。
+
+## 运行状态与断线
+
+`completed` 表示执行结束;`partial` 是部分完成;`needs_clarification` 表示需要补充信息;`paused` 和等待审批均不等于失败。科研状态另行保留,模拟已执行也不代表现实结论已验证。
+
+`status` 独立读取运行快照;`watch` 的事件接口故障不会丢掉已取得的结果和产物,会返回明确提示。每次普通工具确认也有独立 `approval_id`,迟到、重复或缺少编号的决定被拒绝。两端与执行服务需配套更新;旧服务没有确认编号时,客户端不会批准未绑定的操作。
+
+提交采用持久化幂等键;网络响应不明确时保留任务 ID/幂等键,先查原任务,不换键重跑。传输断开不自动取消后台任务;前台等待模式(`background` 省略或 false)在提交工具中按停止会请求取消原任务;后台模式按停止仅结束当前客户端等待,服务端仍可能已受理,要停止必须查询原任务再用 `cancel`。关闭进度观察 `watch` 不取消执行,真正停止要用 `cancel`。
+
+`watch` 每次观察默认最多 30 秒,可设为 0–120 秒;到期返回当前状态,再次调用仍查询同一任务。客户端重启后用 `/paas-tasks` 查询当前服务与账号在本机登记的任务,再用 select 选择 run_id。索引不等于全平台任务列表;更早、其他机器或更换 API Key 前的任务需用 history 查询。任务索引写入失败会明确返回编号,请保留该编号。`AGENTPAAS_BRIDGE_STATE_DIR` 可指定独立客户端状态目录;默认状态目录不存任务正文或 API Key。
+
+## 执行前检查与通用状态卡片
+
+每次提交先检查能力。目录提供版本号时,同一服务、账号、智能体版本的非阻断预检查可缓存 5 秒;版本变化、未知版本或显式 `/paas-check` 会重新调用 PaaS `/agents/{id}/verify`。缓存不包含模型/知识探测,资源仍可能在短窗口内变化。必需资源已确认失败时不提交;可选依赖失败会提示;模型/知识未验证会明确列出,不误报失败,也不声称全链路已验证。自动检查不调用模型、不发送知识查询;`agentpaas_check(check_model=true, knowledge_query="用户查询")` 才做对应探测。探测不能保证后续运行不受服务变化影响。旧 PaaS 缺少检查接口时应配套升级,不能静默绕过。
+
+后台模式收到服务端已受理的 run_id 后保存索引并断开 SSE,再查询一次真实状态。OpenCode 工具完成不代表任务完成。此 fork 的 TUI 为 run/task 显示实际运行状态、阶段、待处理动作、产物 ID、科研版本及已报告用量;费用缺失显示“未报告”。卡片可点击展开全文和审批材料。安装脚本只复制桥接文件,使用卡片需运行包含本次 TUI 更新的 fork。
+
+四类固定输入验收用例与脚本位于 OpenCode 源仓库 `evals/agentpaas-acceptance/` 和 `script/accept-agentpaas.ts`。默认只预览,一次用 `--run` 启动一个真实模型任务,`--resume` 只恢复观察;不自动批准或重跑。自动文件/数值检查、人工质量检查、耗时与用量分开记录,不把任务完成率当科学质量总分。
+
+## 维护与验证
+
+OpenCode 仓库是桥接源码来源,PaaS 中的副本通过其 `scripts/sync_opencode_bridge.py --source /path/to/opencode-bridge` 同步;加 `--check` 检测不同步。修改时须同时带上 lib、tool、agent、plugin 和命令,不能只拷贝一个工具文件。
+
+```bash
+# 在 OpenCode packages/opencode 目录中
+bun test test/tool/agentpaas-run.test.ts test/tool/agentpaas-protocol.test.ts test/tool/agentpaas-admission.test.ts test/tool/agentpaas-client.test.ts test/tool/agentpaas-background.test.ts test/tool/agentpaas-acceptance.test.ts
+bun typecheck
+
+# 在 PaaS 仓库中,使用已安装依赖的 Python
+python scripts/smoke_opencode_client.py --bridge-root /path/to/opencode-bridge --output /path/to/empty-output
+python scripts/smoke_research_integration.py --backend remote --bridge-tool /path/to/opencode-bridge/.opencode/tool/agentpaas_run.ts --output /path/to/another-empty-output
+```
+
+这些 smoke 使用临时 PaaS/执行服务和确定性执行器,不调用真实模型;远程目录是模拟环境,不代替真实 Kubernetes/LocoStack 部署验收。
+
+## 文件删除与回收(PaaS 维护)
+
+`DELETE /files/{file_id}` 只有在存储对象已清理后才返回成功,重复删除已清理文件仍成功。删除失败返回 `FILE_DELETE_PENDING`,普通下载接口停止提供该文件,可重试相同 DELETE。之前签发的对象存储直链仍受其原有效期约束。
+
+旧版只删元数据的文件及删除失败记录,可在 PaaS 服务环境中运行 `python scripts/reclaim_deleted_files.py` 检查;确认目标数据库与存储配置后加 `--apply` 清理,`--limit` 控制每批数量。已清理记录标记为 `purged`,不会反复扫描;脚本不会选择仍在使用的文件。
+
+
+## 断线恢复、提醒与费用边界
+
+提交前保存原请求键。受理阶段等待最多 30 秒;收到 started 后不以此限制整个任务时长。未收到 run_id 就断线时,客户端会按原请求键查询,不重新提交。重启后 `/paas-tasks` 也会检查未确认的提交;可用 `agentpaas_task(action="recover", agent_id="…", idempotency_key="…")` 明确查询。服务端按租户、用户与智能体隔离查询,必要时修复运行索引。查不到只表示“未观察到”,不能据此断言没运行;找回后需继续修订可用 select 选择原任务。
+
+取消只发送一次控制请求,并在最多 6 秒的观察窗口内核对服务端状态。`confirmed` 才表示已确认取消;`requested`/`unconfirmed` 表示任务仍可能运行;`already_terminal` 表示已经结束。后续 status 会重新核对。本机记录写入失败不妨碍发送取消请求。watch 保存事件游标,省略 after 从已保存位置继续;显式 after=0 重放。无新事件时逐步放慢轮询,最长间隔 5 秒。
+
+安装清单包含 `plugin/agentpaas-notify.ts`。OpenCode 运行时每 20 秒检查当前账号在本机最近登记的最多 50 个任务,每批最多 5 个并发只读请求;待审批、距截止不足 60 秒、到期时分别提示一次。同一审批不重复提示,新审批重新提示。关闭 OpenCode 后不发送提醒;不是系统通知服务。提醒只提示,不自动批准,终态任务停止轮询。
+
+用量和费用分别显示完整性;未知或不完整的 0 不表示免费。`max_cost_usd` 仅在执行服务明确声明支持的 CEK simple/react 单智能体上可用;pipeline、科研专用工作流、子智能体和 native 不支持,不能携带其他运行参数覆盖一起使用。预算与更严格的服务端上限取较小值,并在暂停恢复后保留。它依据服务端估算、在调用结算后检查,可能超出一次调用费用,**不是供应商账单硬上限**。其他执行器传入此参数会拒绝,不能声称已限额。

+ 4 - 126
.opencode/README.md

@@ -1,128 +1,6 @@
-# OpenCode ↔ Research67 桥接说明
+# OpenCode ↔ PaaS 客户端
 
-本目录让 OpenCode 客户端作为 Research67 的本地交互入口。OpenCode 侧只负责选择门面、调用 `agentpaas_run`,真正的科研模型、CEK、Guard、知识库、工具调用、预算和 workspace 都在 LambdaAgentPaaS / Research67 侧执行。
+通用入口为 `paas`,支持智能体目录、文件上传下载、任务查询/控制和恢复进度;科研入口为 `research67`。
+安装、配置、完整使用说明和验收命令见 [README.agentpaas.md](README.agentpaas.md)。
 
-## 一句话架构
-
-参赛版使用 PaaS 的 `agentexample/research67/competition.yml`,可只启动本机 API 和执行服务。
-此配置执行证据检索、假设与计划检查、受限 Q124 模拟和反馈修订;下文完整编排流程仅适用于另外注册的完整科研配置。
-科研门面统一使用百炼 `bailian/qwen3.8-flash`,需要向 OpenCode 进程提供 `DASHSCOPE_API_KEY`。
-详细启动和验收方法见配套 PaaS 仓库的 `docs/research67-competition.md`。
-
-`completed` 是执行状态。`research.science.status` 的 `needs_evidence`、`needs_revision`、
-`needs_review` 分别表示证据不足、方案待修订、检查通过待人工审阅,不能表述成科学结论已成立。
-
-```text
-OpenCode CLI
-→ .opencode/agent/research67.md
-→ .opencode/tool/agentpaas_run.ts
-→ AgentPaaS /api/v1/agents/{agent}/run/stream
-→ Research67 统一科研闭环
-```
-
-Research67 统一工作流为:
-
-```text
-科学问题
-→ 本体构建
-→ 本体审计与知识补全
-→ 可证伪假设生成
-→ 研究计划与 Action DAG
-→ 实验规划 / 授权包 / 可执行实验
-→ 结果分析
-→ 论文写作
-→ 数据与主张核验
-→ 论文评审
-→ 反馈合成
-→ 本体再审计与下一轮迭代
-```
-
-## 使用方式
-
-在 `opencode-bridge` 根目录启动:
-
-```bash
-bun run --cwd packages/opencode dev -- --agent research67
-```
-
-之后直接在 OpenCode 中输入科学问题、125 Questions 题号或继续追问即可。同一 OpenCode session 会保留 AgentPaaS thread 和上一轮运行引用。配套版本的本地 PaaS 执行服务和 LocoStack 远程 Agent 均支持研究修订:在新工作区中复制上一轮选定成果,并保留父子版本关系。
-
-## 推荐入口
-
-- `research67`:完整 Research67 对话式入口,推荐默认使用。
-- `research-67-orchestrator`:需要显式调用科研主编排器时使用。
-- `research-67-pipeline`:旧三阶段 Lite 流水线。
-- `research-67-ontology-auditor` / `research-67-ontology-enricher` / `research-67-hypothesis-builder`:本体飞轮相关门面。
-- `research-67-plan-builder` / `research-67-action-reasoner`:研究计划和 Action DAG 推理门面。
-- `research-67-exp-planner` / `research-67-exp-executor` / `research-67-result-analyzer`:实验规划、授权执行和结果分析门面。
-- `research-67-paper-writer` / `research-67-data-verifier` / `research-67-paper-reviewer` / `research-67-review-feedback`:论文写作、核验、评审和反馈迭代门面。
-
-## 配置
-
-`agentpaas_run` 从以下位置读取 AgentPaaS 地址和租户 API Key:
-
-```bash
-export AGENTPAAS_URL=http://127.0.0.1:8000
-export AGENTPAAS_API_KEY=你的_AgentPaaS_API_Key
-```
-
-或写入用户目录:
-
-```json
-{
-  "server": "http://127.0.0.1:8000",
-  "api_key": "你的 AgentPaaS API Key"
-}
-```
-
-路径:`~/.agentpaas/config.json`
-
-注意:这里是 AgentPaaS 租户凭证,不是 Qwen/DashScope token。Qwen/DashScope token 仍由 AgentPaaS 服务端配置管理,不应写入本目录或 Git。
-
-## 边界
-
-- OpenCode 不直接生成科研产物,只展示远端返回结果和实时进度。
-- 每个门面单回合只调用一次 `agentpaas_run`,失败后不得自动重试或改调其他 Agent。
-- 真实湿实验、人体/动物实验和硬件实验需要远端授权门禁;未授权时只能生成实验协议和 `external_authorization_package.json`,不能声称实验验证完成。
-
-## 远程协议与研究修订
-
-`agentpaas_run` 支持 `ag_...` 和 `locostack:<namespace>:<name>` 两种 Agent 身份。新增可选参数:
-
-| 参数                 | 含义                                                             |
-| -------------------- | ---------------------------------------------------------------- |
-| `question_id`        | 1–125 的题号                                                     |
-| `feedback_kind`      | `human`、`program_check`、`execution_result`、`evidence`         |
-| `input_artifact_ids` | 从上一轮已登记成果中选择输入;省略时由 PaaS 选择全部             |
-| `idempotency_key`    | 可选;默认从 OpenCode 消息及请求内容生成稳定键。显式重试保持原键 |
-
-Bridge 在提交前保存原会话和父运行引用,因此重试不会因会话已推进而变成下一版研究。保存位置为 `~/.agentpaas/opencode-conversations/requests/`,可用 `AGENTPAAS_BRIDGE_STATE_DIR` 指定隔离目录。记录不含 API Key 或任务正文;写入失败会停止提交。任务中断时输出原 run ID 和提交键,先查询原状态再决定后续动作。
-
-只运行 OpenCode 与本机 PaaS 时,需要同时启动 PaaS API 和它的 Local Agent Service 执行进程,并配置 `AGENTPAAS_LOCAL_SERVICE_URL` 及两端一致的服务 token。无需连接 LocoStack。配套 PaaS 的 `scripts/smoke_research_integration.py --backend local` 提供隔离的一键验收,详见其 `docs/opencode-local-integration.md`。
-
-使用 `conversation: "continue"` 关联上一轮运行。服务端会检查用户、租户、Agent、session 和成果归属。单次调用出错仍不自动提交另一次研究;已有 run 可以通过 PaaS 快照和事件接口恢复读取。
-
-远程人工确认携带 `approval_id`,走 `/agents/{agent_id}/runs/{run_id}/confirm`。本地 Agent 保留原 trace 确认路由。暂停、取消、跳过和未知状态分别显示,未报告的研究阶段不会自动标为完成。
-
-结果 metadata 中包含 `research` 和登记成果信息。下载地址使用 PaaS API,仍需调用者鉴权,不能作为公开分享链接。`AGENTPAAS_BRIDGE_STATE_DIR` 可以覆盖默认会话状态目录,方便在隔离环境测试;不要在测试中复用真实用户状态。
-
-这些改动需与配套 PaaS/Remote 的 `research-integration` 版本一起使用;尚未实现客户端重启后的全流程自动恢复,也未增加协议版本协商。
-
-## 协议回归
-
-先在仓库根目录和 `.opencode` 安装 Bun 依赖。测试从各包目录运行:
-
-```bash
-cd packages/opencode
-bun test test/tool/agentpaas-run.test.ts test/tool/agentpaas-protocol.test.ts
-bun typecheck
-```
-
-TUI 测试在 `packages/tui`:
-
-```bash
-bun test test/cli/tui/inline-tool-wrap-snapshot.test.tsx
-bun typecheck
-```
-
-完整的本地 HTTP/Q124 验收入口在配套 PaaS 仓库的 `scripts/smoke_research_integration.py`。该测试使用真实 HTTP 和仿真执行器;Loco 发现目录为测试夹具,不代表真实 Kubernetes 已验收。
+桥接源码以本仓库为准;PaaS 中的副本通过清单同步。门面继承用户选择的 OpenCode 入口模型,任务模型由 PaaS 配置。

+ 37 - 0
.opencode/agent/paas.md

@@ -0,0 +1,37 @@
+---
+mode: primary
+steps: 10
+color: "#0F766E"
+description: PaaS 通用入口:选择智能体、上传材料、执行任务、恢复进度、审批与下载产物。
+tools:
+  "*": false
+  agentpaas_run: true
+  agentpaas_check: true
+  agentpaas_tasks: true
+  agentpaas_agents: true
+  agentpaas_task: true
+  agentpaas_upload: true
+  agentpaas_download: true
+---
+
+你是 LambdaAgentPaaS 的客户端入口。通过工具取得事实,不自行模拟远端执行、虚构能力或产物。
+
+首次选择智能体时调用 agentpaas_agents,按实际目录的用途匹配用户要求;目标不明确或同名时,让用户选择。使用目录中的 agent_id,不猜 ID。用户只询问能力时只查目录,不启动任务。
+
+用户要求处理本机文件时,先用 agentpaas_upload 上传明确指定的文件,再把返回的 file_id 放到 agentpaas_run.input_file_ids。不得把客户端路径当成服务端 work_dir,不自动上传整个目录、隐藏文件或密钥。
+
+每个用户回合最多新建一次任务。agentpaas_run 使用用户原始任务及明确约束,默认 conversation=continue;新课题用 new。不可因失败、超时或断线换键重跑、拆分请求或改调其他智能体。
+
+“进度如何”“刚才任务断了”“继续看”用 agentpaas_task 的 status/watch 查询原任务;暂停、恢复、停止分别用 pause/resume/cancel。watch 结束不等于后台任务结束。已暂停任务用原 checkpoint 恢复,不能重新提交。
+
+用户选择历史任务继续修改,或在新 OpenCode 会话中接着旧任务工作时,先调用 agentpaas_task 的 select 并指定 run_id。select 绑定远端会话和父版本,不执行任务;status/history 仅查看,不切换当前上下文。选中后先根据状态处理运行、暂停或审批,再提交用户要求的修订。
+
+用户“继续”有歧义时先查原任务:运行中用 watch;已暂停且用户要求恢复时用 resume;等待确认时展示材料并等用户作决定;只有已完成或明确要求修订时才创建下一轮。审批必须展示具体材料;approve/reject 只表达用户真实决定,不得自行批准。
+
+区分 completed、partial、needs_clarification、paused、waiting_human、failed。需要澄清时向用户转述问题,部分完成时保留已有结果。科研执行完成不能表述为科学结论已验证。
+
+用户要查看或取回文件时,使用 agentpaas_download 下载实际 artifact_id,然后展示工具返回的本机文件链接。没有产物 ID 时先查 status。不得把服务端绝对路径说成本机文件,不编造公开下载链接。文件已经下载时复用本机链接;不要重复下载覆盖文件。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。
+
+若提交断线且没有 run_id,用原 idempotency_key 调用 task/recover 或 tasks;查不到也不得换键重跑。取消后必须按 cancellation_status 说明已确认或仍待核查,不能把请求成功当作已经停止。审批提醒仅在客户端运行时可用,显示 expires_at 并让用户决定。用量完整性未确认时不得声称免费或准确成本。用户要求费用限制时,仅对预检查 client_budget_supported=true 的智能体传 max_cost_usd,解释这是估算费用的结算后检查,其他引擎不得承诺限额。

+ 10 - 1
.opencode/agent/research-67-action-reasoner.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#F59E0B"
 description: 本体动作推理员,负责证据评估、证伪判断、综合结论和本体再审计。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 本体动作推理员入口(research-67-action-reasoner)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-claim-verifier.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#EF4444"
 description: 本体主张核验员,检查科学主张、来源、证据状态和论文表达是否一致。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 本体主张核验员入口(research-67-claim-verifier)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-data-verifier.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#EAB308"
 description: 数据核验员。核验论文中的数据、引用、表述和强主张是否与证据及 artifact 一致。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是数据核验员(research-67-data-verifier)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-exp-executor.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#EC4899"
 description: 实验执行员。仅在授权且本地可执行时运行计算/数据实验,收集原始结果和执行日志。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是实验执行员(research-67-exp-executor)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-exp-planner.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#D946EF"
 description: 实验规划师。基于假设、本体缺口和研究计划生成实验方案、指标、对照和授权需求。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是实验规划师(research-67-exp-planner)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-hypothesis-builder.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#10B981"
 description: 可证伪假设构建员,基于已核验证据生成科学假设与可证伪条件。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 可证伪假设构建员入口(research-67-hypothesis-builder)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-idea-analyst.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#8B5CF6"
 description: 创意分析师。分析研究想法的可研究性、学科范围、证据类型、风险和方向。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是创意分析师(research-67-idea-analyst)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-lit-searcher.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#A855F7"
 description: 文献检索员。进行结构化文献检索和来源组织,为本体补全和论文写作提供候选证据。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是文献检索员(research-67-lit-searcher)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-ontology-auditor.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#0EA5E9"
 description: 本体完备性审计员,检查实体、机制、证据、关系、假设和可证伪条件缺口。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 本体完备性审计员入口(research-67-ontology-auditor)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-ontology-enricher.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#14B8A6"
 description: 本体证据补全员,调用知识库和检索工具补充实体、机制、证据、未知项和关系。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 本体证据补全员入口(research-67-ontology-enricher)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-ontology-experiment-planner.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#A855F7"
 description: 本体实验规划员,围绕假设、证据缺口和可证伪条件生成实验协议。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 本体实验规划员入口(research-67-ontology-experiment-planner)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-orchestrator.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#4F46E5"
 description: Research67 科研主编排器入口,调度多智能体科研闭环。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 Research67 科研主编排器入口(research-67-orchestrator)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-paper-reviewer.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#22C55E"
 description: 论文评审员。从科学性、证据充分性、可复现性和表达质量角度评审产物。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是论文评审员(research-67-paper-reviewer)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-paper-writer.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#F97316"
 description: 论文撰写员。基于已核验证据和 action receipts 生成中英论文、LaTeX、BibTeX 和 PDF。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是论文撰写员(research-67-paper-writer)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-pipeline.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#6366F1"
 description: Research67 Lite 三阶段研究草稿流水线入口。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 Research67 Lite 流水线入口(research-67-pipeline)。
@@ -30,3 +35,7 @@ tools:
 > 注:Research67 Lite 当前使用 pipeline,不调用 orchestrator;agentpaas_run 会按 agent_name 动态解析当前 Agent ID。
 
 此三阶段研究草稿流水线不执行真实实验;请以实际返回的阶段和产物为准。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-plan-builder.md

@@ -1,12 +1,17 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 steps: 2
 color: "#84CC16"
 description: 本体动作研究计划员,把假设本体编译为研究计划和 Action DAG。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 本体动作研究计划员入口(research-67-plan-builder)。
@@ -24,3 +29,7 @@ tools:
 只允许调用一次 `agentpaas_run`。工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果,不得在 OpenCode 一侧伪造产物,不得失败后擅自重试或改调其他 Agent。
 
 > 注:OpenCode 门面使用 MiMo 只负责桥接;实际科研模型、CEK、Guard、工具、知识库、预算和 workspace 均由 AgentPaaS 中注册的 DashScope Qwen Agent 配置决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-result-analyst.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#F43F5E"
 description: 结果分析师。分析实验结果数据,生成统计检验、对比表格和可视化图表。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是结果分析师(research-67-result-analyst)。
@@ -19,3 +24,7 @@ tools:
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-result-analyzer.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#F43F5E"
 description: 结果分析师。分析实验结果或外部回传数据,生成统计检验、对比表格、图表和观察补丁。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是结果分析师(research-67-result-analyzer)。
@@ -21,3 +26,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:该门面名称与远端 `research-67-result-analyzer` 执行器保持一致;旧的 `research-67-result-analyst` 文件仅保留兼容。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 10 - 1
.opencode/agent/research-67-review-feedback.md

@@ -1,11 +1,16 @@
 ---
 mode: subagent
-model: bailian/qwen3.8-flash
 color: "#06B6D4"
 description: 评审反馈合成器。将评审意见转成下一轮 action、修订指令和本体补丁。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是评审反馈合成器(research-67-review-feedback)。
@@ -20,3 +25,7 @@ tools:
 必须忠实返回 `agentpaas_run` 的结果,不要自己编造或补充科研内容。调用一次即可。
 
 > 注:OpenCode 门面只负责桥接;实际科研模型、工具、知识库、Guard 和 workspace 均由 AgentPaaS 侧决定。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 22 - 2
.opencode/agent/research67.md

@@ -1,12 +1,17 @@
 ---
 mode: primary
-model: bailian/qwen3.8-flash
-steps: 3
+steps: 8
 color: "#0F766E"
 description: 科学假设与研究计划入口:转发研究问题、展示证据和检查结果、继续反馈修订。
 tools:
   "*": false
   "agentpaas_run": true
+  "agentpaas_check": true
+  "agentpaas_tasks": true
+  "agentpaas_agents": true
+  "agentpaas_task": true
+  "agentpaas_upload": true
+  "agentpaas_download": true
 ---
 
 你是 Research67 的交互入口,通过 agentpaas_run 连接本机或远端 LambdaAgentPaaS。
@@ -30,8 +35,23 @@ tools:
 status=completed 只表示执行结束。若有 research.science.status,优先原样解释:
 - needs_evidence:证据不足。
 - needs_revision:方案待修订。
+- review_pending:审阅尚未完成,已有方案已保留;说明失败阶段,不把审阅格式错误说成科学假设错误。
 - needs_review:程序检查通过,待人工审阅。
 引用原句存在不等于主张成立;模拟结果不等于真实人群实验。不得声称科学问题已解决。
+研究状态与模拟状态分别展示。execution_status=conditional_diagnostic_completed 表示受限模型诊断已执行,
+可以与 needs_evidence 并存;不能因为证据不足就说没有执行,也不能因为执行了就说证据充分。
+知识库中的方法说明、术语和文献线索不算独立证据;是否使用了可引用原文,以后端来源记录为准。
 
 工具返回后简洁展示研究状态、主要结论及限制、修改依据和产物链接,保留实际工作目录。
+科研结果优先展示后端返回的原题与本次子问题、对原题的贡献、研究方法和资源约束。
+保留候选假设的前提、条件预测、区分它们的验证设计以及无法判断的情形;不得省略关键限制后宣称方案已验证。
+说明哪些反馈已修改、哪些被有理由地保留、哪些仍缺信息,并转述后端给出的停止原因。
+保留关键证据的不确定性、任务范围收窄及其依据。引用材料是摘要还是全文节选,以后端记录为准;不能把节选说成已通读全文。
+原句核验、模型证据审阅、模型设计审阅和人工审阅是不同层次;模型给出的通过意见不是科学事实认证,也不是独立评估得分。
+用户反馈指向某个假设、证据或动作时,在 input 中完整保留相应编号和意见;资源、预算和时间限制也必须原样转发。
+未给出题号时继续保留原始问题文本,不猜题号;后端未返回的任务卡字段或版本差异不得自行补造。
 对纯问候或能力查询,仅转述远端确认的能力,不扩写未确认流程,不自行启动研究。
+
+客户端辅助操作:查询能力用 agentpaas_agents;用户材料用 agentpaas_upload 上传并将 file_id 传给 agentpaas_run.input_file_ids;进度/暂停/恢复/审批用 agentpaas_task 操作原 run_id;明确选择历史任务继续修订时,先用 agentpaas_task 的 select 绑定该 run_id 的会话与父版本,单纯查询不切换上下文;取回产物用 agentpaas_download。辅助操作不受“每回合只执行一次 agentpaas_run”的限制。不要把查询、下载或恢复变成新的科研任务,不把服务端路径当成本机路径。只有用户明确要求新一轮修订时才再次运行。
+
+长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。

+ 39 - 0
.opencode/agentpaas-manifest.json

@@ -0,0 +1,39 @@
+{
+  "schema_version": 1,
+  "files": [
+    "tool/agentpaas_run.ts",
+    "tool/agentpaas.ts",
+    "lib/agentpaas.ts",
+    "agent/paas.md",
+    "command/paas.md",
+    "command/paas-status.md",
+    "env.d.ts",
+    "agent/research-67-action-reasoner.md",
+    "agent/research-67-claim-verifier.md",
+    "agent/research-67-data-verifier.md",
+    "agent/research-67-exp-executor.md",
+    "agent/research-67-exp-planner.md",
+    "agent/research-67-hypothesis-builder.md",
+    "agent/research-67-idea-analyst.md",
+    "agent/research-67-lit-searcher.md",
+    "agent/research-67-ontology-auditor.md",
+    "agent/research-67-ontology-enricher.md",
+    "agent/research-67-ontology-experiment-planner.md",
+    "agent/research-67-orchestrator.md",
+    "agent/research-67-paper-reviewer.md",
+    "agent/research-67-paper-writer.md",
+    "agent/research-67-pipeline.md",
+    "agent/research-67-plan-builder.md",
+    "agent/research-67-result-analyst.md",
+    "agent/research-67-result-analyzer.md",
+    "agent/research-67-review-feedback.md",
+    "agent/research67.md",
+    "README.agentpaas.md",
+    "agentpaas-manifest.json",
+    "command/paas-tasks.md",
+    "command/paas-check.md",
+    "lib/recovery.ts",
+    "lib/approval-monitor.ts",
+    "plugin/agentpaas-notify.ts"
+  ]
+}

+ 7 - 0
.opencode/command/paas-check.md

@@ -0,0 +1,7 @@
+---
+description: 检查 PaaS 智能体依赖是否可用
+agent: paas
+---
+
+按用户指定目标调用 agentpaas_check:$ARGUMENTS
+目标不明确时先查目录。默认不调用模型。用户要求验证模型连通时才启用 check_model;知识检索使用用户提供的 knowledge_query。解释失败与未验证的区别,不启动研究任务。

+ 7 - 0
.opencode/command/paas-status.md

@@ -0,0 +1,7 @@
+---
+description: 查询或恢复查看已有 PaaS 任务
+agent: paas
+---
+
+查询已有任务:$ARGUMENTS
+使用 agentpaas_task 的 status 或 watch;不调用 agentpaas_run。目标不明确时先查历史或请用户指定。

+ 7 - 0
.opencode/command/paas-tasks.md

@@ -0,0 +1,7 @@
+---
+description: 找回此账号在本机登记的 PaaS 任务
+agent: paas
+---
+
+调用 agentpaas_tasks,展示运行、等待确认、暂停、结束和暂不可查询的任务:$ARGUMENTS
+只观察已有任务,不重新提交、不自动批准。用户需要继续修订时,选定明确的 agent_id 和 run_id 后用 agentpaas_task select。

+ 7 - 0
.opencode/command/paas.md

@@ -0,0 +1,7 @@
+---
+description: 通过 PaaS 智能体处理任务或查询能力
+agent: paas
+---
+
+按 PaaS 客户端规则处理用户请求:$ARGUMENTS
+没有具体请求时,调用 agentpaas_agents 展示可用智能体;不要自行启动任务。

+ 460 - 0
.opencode/lib/agentpaas.ts

@@ -0,0 +1,460 @@
+import { link, mkdir, readdir, rename, rm } from "node:fs/promises"
+import { homedir } from "node:os"
+import path from "node:path"
+import { setTimeout } from "node:timers/promises"
+
+const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
+export type Connection = { url: string; apiKey: string }
+export type ConversationState = { threadID: string; runID: string }
+type AgentListItem = { id: string; name: string }
+const revisions = new Map<string, string>()
+const probes = new Map<
+  string,
+  { at: number; report: Record<string, unknown> & { blocked: boolean; summary: string } }
+>()
+
+export async function requestJSON(config: Connection, route: string, signal: AbortSignal, init: RequestInit = {}) {
+  const response = await fetch(`${config.url}/api/v1${route}`, {
+    ...init,
+    headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json", ...init.headers },
+    redirect: "error",
+    signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
+  }).catch((error) => {
+    if (signal.aborted) throw error
+    throw new Error(
+      `无法取得 PaaS 响应(${config.url});请检查服务、地址与网络。已有任务请保留原 run_id 查询,不要重复提交。${getString(asRecord(error)?.code)}`,
+    )
+  })
+  if (!response.ok) {
+    const detail = (await response.text()).replaceAll(config.apiKey, "[redacted]").slice(0, 500)
+    throw new Error(`AgentPaaS HTTP ${response.status}: ${detail}`)
+  }
+  const result = asRecord(await response.json())
+  if (!result) throw new Error("AgentPaaS 返回了无效 JSON 对象")
+  return result
+}
+
+export async function verifyAgent(
+  config: Connection,
+  agentID: string,
+  signal: AbortSignal,
+  options = { check_model: false, knowledge_query: "" },
+  cached = false,
+): Promise<Record<string, unknown> & { blocked: boolean; summary: string }> {
+  const scope = digest(canonicalJSON([config.url, config.apiKey, agentID]))
+  const revision = revisions.get(scope)
+  const key = `${scope}:${revision}`
+  const useCache = cached && !options.check_model && !options.knowledge_query
+  if (!useCache) probes.delete(key)
+  const previous = probes.get(key)
+  if (useCache && revision && previous && Date.now() - previous.at < 5000)
+    return { ...previous.report, cache_hit: true }
+  const report = await requestJSON(config, `/agents/${encodeURIComponent(agentID)}/verify`, signal, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(options),
+  })
+  if (
+    !["verified", "unverified", "degraded", "blocked"].includes(getString(report.status)) ||
+    !Array.isArray(report.checks)
+  )
+    throw new Error("PaaS 能力检查返回无效报告,任务未提交;请更新 PaaS 后重试检查。")
+  const checks = report.checks.map(asRecord)
+  if (
+    checks.some(
+      (item) =>
+        !item ||
+        typeof item.required !== "boolean" ||
+        !["passed", "failed", "not_checked"].includes(getString(item.status)),
+    )
+  )
+    throw new Error("PaaS 能力检查条目无效,任务未提交。")
+  const blocked = report.status === "blocked" || checks.some((item) => item?.required && item.status === "failed")
+  const lines = checks.flatMap((item) =>
+    item && item.status !== "passed"
+      ? [
+          `${item.required ? "必需" : "可选"} ${getString(item.kind)}/${getString(item.name)}:${item.status === "failed" ? "失败" : "未验证"}${item.error_code ? ` (${getString(item.error_code)})` : ""}`,
+        ]
+      : [],
+  )
+  const result = {
+    ...report,
+    blocked,
+    summary: [blocked ? "必需能力不可用,任务未提交。" : "未发现已确认失效的必需能力。", ...lines].join("\n"),
+  }
+  if (useCache && revision && !blocked) {
+    if (probes.size > 100) probes.clear()
+    probes.set(key, { at: Date.now(), report: result })
+  }
+  return result
+}
+
+type TrackedRun = { agent_id: string; agent_name: string; run_id: string; thread_id: string; recorded_at: string }
+
+function trackedRunDirectory(config: Connection) {
+  return path.join(agentPaaSConversationDir(), "runs", digest(canonicalJSON([config.url, config.apiKey])))
+}
+
+export async function trackRun(config: Connection, run: Omit<TrackedRun, "recorded_at">) {
+  if (!isSupportedAgentID(run.agent_id) || !run.run_id) throw new Error("无法保存无效任务编号")
+  const directory = trackedRunDirectory(config)
+  await mkdir(directory, { recursive: true, mode: 0o700 })
+  const target = path.join(directory, `${digest(canonicalJSON([run.agent_id, run.run_id]))}.json`)
+  const temporary = `${target}.${crypto.randomUUID()}.tmp`
+  try {
+    await Bun.write(temporary, JSON.stringify({ ...run, recorded_at: new Date().toISOString() }), { mode: 0o600 })
+    await rename(temporary, target)
+  } finally {
+    await rm(temporary, { force: true })
+  }
+}
+
+export async function trackedRuns(config: Connection, limit: number) {
+  const directory = trackedRunDirectory(config)
+  const files = await readdir(directory).catch((error: NodeJS.ErrnoException) => {
+    if (error.code === "ENOENT") return []
+    throw error
+  })
+  const rows = await Promise.all(
+    files
+      .filter((file) => /^[a-f0-9]{64}\.json$/.test(file))
+      .map(async (file) => {
+        const row = asRecord(await Bun.file(path.join(directory, file)).json())
+        if (
+          !row ||
+          !isSupportedAgentID(getString(row.agent_id)) ||
+          !getString(row.run_id) ||
+          !getString(row.recorded_at)
+        )
+          throw new Error("任务索引损坏,请用 agentpaas_task history 查询服务端历史。")
+        return row as TrackedRun
+      }),
+  )
+  return rows.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, limit)
+}
+
+export async function listAgents(config: Connection, signal: AbortSignal, wanted?: string) {
+  for (let attempt = 0; ; attempt++) {
+    const payload = await requestJSON(config, "/agents", signal)
+    if (!Array.isArray(payload.agents)) throw new Error("AgentPaaS Agent 列表响应格式无效")
+    const agents = payload.agents.flatMap((item): Array<Record<string, unknown> & AgentListItem> => {
+      const record = asRecord(item)
+      return record && typeof record.id === "string" && typeof record.name === "string"
+        ? [{ ...record, id: record.id, name: record.name }]
+        : []
+    })
+    const status = getString(asRecord(payload.discovery)?.status)
+    const found = wanted && agents.some((agent) => agent.id === wanted || agent.name === wanted)
+    if (found || !["pending", "refreshing"].includes(status) || attempt >= 7)
+      return { agents, discovery: asRecord(payload.discovery) ?? { status: "unknown" } }
+    // Only read-only discovery is retried. Never replay a control mutation here.
+    await setTimeout(Math.min(250 * (attempt + 1), 1000), undefined, { signal })
+  }
+}
+
+export async function resolveAgentID(config: Connection, selector: string, signal: AbortSignal) {
+  const name = selector.trim()
+  if (!name) throw new Error("必须提供 agent_name 或 agent_id")
+  const catalog = await listAgents(config, signal, name)
+  const byID = catalog.agents.filter((item) => item.id === name)
+  const matches = byID.length ? byID : catalog.agents.filter((item) => item.name === name)
+  if (!matches.length) {
+    const status = getString(catalog.discovery.status)
+    if (["pending", "refreshing", "unavailable"].includes(status))
+      throw new Error(`AgentPaaS 尚未找到“${name}”,远端发现状态:${status};请稍后刷新目录,任务未提交。`)
+    throw new Error(`AgentPaaS 中没有名为“${name}”的 active Agent`)
+  }
+  if (matches.length > 1)
+    throw new Error(
+      `AgentPaaS 中存在 ${matches.length} 个名为“${name}”的 active Agent,请使用目录中的 agent_id:${matches.map((item) => item.id).join(", ")}`,
+    )
+  if (!isSupportedAgentID(matches[0].id)) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
+  const revision = matches[0].current_version ?? matches[0].agent_version
+  const scope = digest(canonicalJSON([config.url, config.apiKey, matches[0].id]))
+  if (revision !== undefined)
+    revisions.set(scope, canonicalJSON([revision, matches[0].runtime_mode, matches[0].runtime_binding]))
+  else revisions.delete(scope)
+  return matches[0].id
+}
+
+// Parse the two supported ID namespaces; never accept URL/path syntax or decoded input.
+// Every route still encodes the complete ID as one path segment.
+export function isSupportedAgentID(id: string): boolean {
+  if (id.length > 512) return false
+  if (id.startsWith("ag_")) return /^ag_[A-Za-z0-9_-]+$/.test(id)
+  const parts = id.split(":")
+  if (parts.length !== 3 || parts[0] !== "locostack") return false
+  const namespace = parts[1]
+  const name = parts[2]
+  const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
+  return (
+    namespace.length <= 63 &&
+    label.test(namespace) &&
+    name.length <= 253 &&
+    name.split(".").every((part) => part.length <= 63 && label.test(part))
+  )
+}
+
+const conversationCache = new Map<string, ConversationState>()
+
+export function digest(value: string) {
+  return new Bun.CryptoHasher("sha256").update(value).digest("hex")
+}
+
+export function canonicalJSON(value: unknown): string {
+  if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`
+  const record = asRecord(value)
+  if (record)
+    return `{${Object.keys(record)
+      .sort()
+      .filter((key) => record[key] !== undefined)
+      .map((key) => `${JSON.stringify(key)}:${canonicalJSON(record[key])}`)
+      .join(",")}}`
+  return JSON.stringify(value) ?? "null"
+}
+
+export async function requestContext(
+  config: { url: string; apiKey: string },
+  agentID: string,
+  key: string,
+  current: ConversationState & { intent: string },
+) {
+  const directory = path.join(agentPaaSConversationDir(), "requests")
+  const filePath = path.join(directory, `${digest(canonicalJSON([config.url, config.apiKey, agentID, key]))}.json`)
+  await mkdir(directory, { recursive: true, mode: 0o700 })
+  const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
+  await Bun.write(temporary, JSON.stringify(current), { mode: 0o600 })
+  // link is atomic and never replaces the first submission's saved context.
+  const created = await link(temporary, filePath)
+    .then(
+      () => true,
+      (error: NodeJS.ErrnoException) => {
+        if (error.code === "EEXIST") return false
+        throw error
+      },
+    )
+    .finally(() => rm(temporary, { force: true }))
+  const saved = asRecord(await Bun.file(filePath).json())
+  if (!saved || typeof saved.threadID !== "string" || typeof saved.runID !== "string")
+    throw new Error("AgentPaaS 提交记录损坏,停止提交;请先检查原运行状态。")
+  if (saved.intent !== current.intent) throw new Error("AgentPaaS 幂等键已用于不同请求;修改后的任务必须使用新键。")
+  return { threadID: saved.threadID, runID: saved.runID, created }
+}
+
+export async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
+  const cacheKey = `${sessionID}\n${key}`
+  const cached = conversationCache.get(cacheKey)
+  if (cached) return cached
+
+  const file = Bun.file(conversationStatePath(sessionID))
+  if (!(await file.exists())) return undefined
+  try {
+    const parsed: unknown = await file.json()
+    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
+    const value = (parsed as Record<string, unknown>)[key]
+    if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
+    const threadID = getString((value as Record<string, unknown>).thread_id)
+    const runID = getString((value as Record<string, unknown>).run_id)
+    if (!threadID || !runID) return undefined
+    const state = { threadID, runID }
+    conversationCache.set(cacheKey, state)
+    return state
+  } catch {
+    return undefined
+  }
+}
+
+const conversationWrites = new Map<string, Promise<void>>()
+
+export async function saveConversationState(
+  sessionID: string,
+  key: string,
+  state: ConversationState,
+  required = false,
+) {
+  const filePath = conversationStatePath(sessionID)
+  const pending = (conversationWrites.get(filePath) ?? Promise.resolve())
+    .catch(() => undefined)
+    .then(async () => {
+      const file = Bun.file(filePath)
+      const saved = (await file.exists()) ? asRecord(await file.json()) : {}
+      if (!saved) throw new Error("当前会话记录损坏,未覆盖已有记录")
+      saved[key] = { thread_id: state.threadID, run_id: state.runID }
+      await mkdir(agentPaaSConversationDir(), { recursive: true, mode: 0o700 })
+      const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
+      try {
+        await Bun.write(temporary, `${JSON.stringify(saved, null, 2)}\n`, { mode: 0o600 })
+        await rename(temporary, filePath)
+      } finally {
+        await rm(temporary, { force: true })
+      }
+    })
+  conversationWrites.set(filePath, pending)
+  try {
+    await pending
+  } catch (error) {
+    if (required) throw error
+  } finally {
+    if (conversationWrites.get(filePath) === pending) conversationWrites.delete(filePath)
+  }
+  conversationCache.set(`${sessionID}\n${key}`, state)
+}
+
+function conversationStatePath(sessionID: string) {
+  return path.join(agentPaaSConversationDir(), `${sessionID.replace(/[^A-Za-z0-9._-]/g, "_")}.json`)
+}
+
+export async function loadAgentPaaSConfig() {
+  const configPath = agentPaaSConfigPath()
+  const file = Bun.file(configPath)
+  let saved: Record<string, unknown> = {}
+  if (await file.exists()) {
+    try {
+      const parsed: unknown = await file.json()
+      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("根节点必须是 JSON 对象")
+      saved = parsed as Record<string, unknown>
+    } catch (err) {
+      throw new Error(`AgentPaaS 配置文件无效:${configPath}\n${err instanceof Error ? err.message : String(err)}`)
+    }
+  }
+
+  const savedUrl = typeof saved.server === "string" ? saved.server.trim() : ""
+  const savedApiKey = typeof saved.api_key === "string" ? saved.api_key.trim() : ""
+  const url = (process.env.AGENTPAAS_URL?.trim() || savedUrl || DEFAULT_AGENTPAAS_URL).replace(/\/+$/, "")
+  const apiKey = process.env.AGENTPAAS_API_KEY?.trim() || savedApiKey
+  const parsedURL = new URL(url)
+  if (
+    !["http:", "https:"].includes(parsedURL.protocol) ||
+    parsedURL.username ||
+    parsedURL.password ||
+    parsedURL.search ||
+    parsedURL.hash
+  )
+    throw new Error("AgentPaaS 地址必须是无凭证、查询参数和片段的 HTTP(S) 地址")
+  if (apiKey) return { url, apiKey }
+
+  throw new Error(`AgentPaaS 尚未配置。请创建 ${configPath}:
+{
+  "server": "${DEFAULT_AGENTPAAS_URL}",
+  "api_key": "你的 AgentPaaS API Key"
+}
+也可以通过 AGENTPAAS_URL 和 AGENTPAAS_API_KEY 环境变量临时覆盖。`)
+}
+
+function agentPaaSHome() {
+  return process.env.HOME?.trim() || homedir()
+}
+
+function agentPaaSConfigPath() {
+  return path.join(agentPaaSHome(), ".agentpaas", "config.json")
+}
+
+export function agentPaaSConversationDir() {
+  return (
+    process.env.AGENTPAAS_BRIDGE_STATE_DIR?.trim() || path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
+  )
+}
+
+export function asRecord(value: unknown): Record<string, unknown> | undefined {
+  if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>
+}
+export function getString(value: unknown): string {
+  return typeof value === "string" ? value : ""
+}
+
+export function scientificLabel(status: unknown): string {
+  if (status === "needs_evidence") return "证据不足"
+  if (status === "needs_revision") return "方案待修订"
+  if (status === "review_pending") return "审阅未完成,已保留方案"
+  if (status === "needs_review") return "方案检查通过,待人工审阅"
+  return "研究状态待核查"
+}
+
+export function formatRunResult(agentName: string, run: Record<string, unknown>, extra: Record<string, unknown> = {}) {
+  const status =
+    asRecord(run.pending_confirmation)?.status === "pending" && run.status === "running"
+      ? "waiting_confirmation"
+      : getString(run.status) || "unknown"
+  const labels: Record<string, string> = {
+    completed: "已完成",
+    partial: "部分完成",
+    needs_clarification: "需要补充信息",
+    failed: "失败",
+    cancelled: "已取消",
+    paused: "已暂停",
+    waiting_human: "等待确认",
+    waiting_confirmation: "等待确认",
+    queued: "排队中",
+    running: "运行中",
+    resuming: "恢复中",
+    unknown: "状态待核查",
+  }
+  const artifacts = Array.isArray(run.artifacts) ? run.artifacts : []
+  const metadata: Record<string, unknown> = { ...run, ...extra, status, artifacts }
+  const science = asRecord(asRecord(run.research)?.science)
+  const hints: Record<string, string> = {
+    partial: "任务已部分完成,保留已有产物;请查看缺失项后继续补充。",
+    needs_clarification: "需要补充信息后才能继续;请回答智能体提出的问题,不要重新执行同一请求。",
+    paused: `任务已暂停。checkpoint_id=${getString(run.checkpoint_id) || "未提供"};使用 agentpaas_task 的 resume 恢复原任务。`,
+    waiting_human: "任务正在等待人工确认;使用 agentpaas_task 查看审批材料并提交决定,这不是任务失败。",
+    waiting_confirmation: "任务正在等待工具确认;使用 agentpaas_task 查看并确认原任务。",
+    cancelled: "任务已被取消。",
+    queued: "任务已排队;使用 agentpaas_task 的 watch 查看进度。",
+    running: "任务仍在运行;使用 agentpaas_task 的 watch 恢复查看,不要重新提交。",
+    resuming: "任务正在恢复;使用 agentpaas_task 的 watch 查看进度。",
+    failed: "失败严禁自动重跑、拆分任务或调用其他 subagent;必须先向用户报告真实错误和已有产物,等待用户明确指令。",
+  }
+  const artifactLines = artifacts.flatMap((item) => {
+    const artifact = asRecord(item)
+    if (!artifact || !getString(artifact.artifact_id)) return []
+    return [`- ${getString(artifact.name).replace(/[\r\n]/g, " ") || "文件"}(artifact_id=${artifact.artifact_id})`]
+  })
+  return {
+    title:
+      status === "completed"
+        ? science
+          ? scientificLabel(science.status)
+          : `AgentPaaS ${agentName}`
+        : `AgentPaaS ${agentName}(${labels[status] ?? "状态待核查"})`,
+    output: [
+      ...(science ? [`研究状态:${scientificLabel(science.status)}。执行结束不代表科学结论成立。`] : []),
+      ...(science?.execution_status === "conditional_diagnostic_completed"
+        ? ["模拟状态:已完成设定模型下的诊断;证据充分性与现实适用性须另行判断。"]
+        : []),
+      ...(status === "failed"
+        ? [`[AgentPaaS 执行失败] ${getString(run.error_message) || getString(asRecord(run.error)?.message)}`]
+        : []),
+      getString(run.output),
+      ...(run.cancellation_status
+        ? [
+            {
+              requested: "取消请求已发送,仍待服务端确认。",
+              confirmed: "服务端已确认取消。",
+              unconfirmed: "取消未确认,任务可能仍在执行;请查询原任务。",
+              already_terminal: "任务已结束,不能将其标记为由本次取消操作停止。",
+            }[getString(run.cancellation_status)] || "取消状态待核查。",
+          ]
+        : []),
+      hints[status] ?? "",
+      getString(run.persistence_warning),
+      ...(metadata.run_id ? [`agent_id=${getString(metadata.agent_id)} · run_id=${metadata.run_id}`] : []),
+      ...(asRecord(run.research)?.revision !== undefined ? [`研究版本:${asRecord(run.research)?.revision}`] : []),
+      `用量:tokens=${run.total_tokens ?? asRecord(run.usage)?.total_tokens ?? "未报告"}(${asRecord(run.usage)?.usage_complete === true ? "计量完整" : "完整性未确认"});费用 USD=${run.cost_usd ?? asRecord(run.usage)?.cost_usd ?? "未报告"}(${asRecord(run.usage)?.cost_complete === true ? "计量完整" : "完整性未确认,不能据 0 判断免费"})`,
+      ...(run.workspace_path ? [`服务端工作目录:${run.workspace_path}`] : []),
+      ...(artifactLines.length ? ["产物(使用 agentpaas_download 下载到本机):", ...artifactLines] : []),
+      ...(run.pending_approval ? [`待确认材料:${JSON.stringify(run.pending_approval)}`] : []),
+      ...(run.pending_confirmation ? [`待确认工具:${JSON.stringify(run.pending_confirmation)}`] : []),
+      ...(asRecord(run.pending_confirmation)?.expires_at
+        ? [`工具确认截止:${asRecord(run.pending_confirmation)?.expires_at};超时不会自动批准。`]
+        : []),
+      ...(run.pending_approval
+        ? [
+            `人工审阅截止:${getString(asRecord(run.pending_approval)?.expires_at) || "服务端未指定"};与普通工具确认分开处理。`,
+          ]
+        : []),
+    ]
+      .filter(Boolean)
+      .join("\n"),
+    metadata,
+  }
+}

+ 60 - 0
.opencode/lib/approval-monitor.ts

@@ -0,0 +1,60 @@
+import { asRecord, getString, requestJSON, trackedRuns, type Connection } from "./agentpaas"
+
+export function approvalMonitor(config: Connection, notify: (message: string) => Promise<void>) {
+  const abort = new AbortController()
+  const seen = new Set<string>()
+  const terminal = new Set<string>()
+  let busy = false
+  const poll = async () => {
+    if (busy || abort.signal.aborted) return
+    busy = true
+    try {
+      const runs = await trackedRuns(config, 50)
+      for (let offset = 0; offset < runs.length && !abort.signal.aborted; offset += 5) {
+        await Promise.all(
+          runs.slice(offset, offset + 5).map(async (row) => {
+            const identity = `${row.agent_id}:${row.run_id}`
+            if (terminal.has(identity)) return
+            const run = await requestJSON(
+              config,
+              `/agents/${encodeURIComponent(row.agent_id)}/runs/${encodeURIComponent(row.run_id)}`,
+              abort.signal,
+            ).catch(() => undefined)
+            if (!run) return
+            if (["completed", "partial", "failed", "cancelled"].includes(getString(run.status))) {
+              terminal.add(identity)
+              return
+            }
+            const confirmation = asRecord(run.pending_confirmation),
+              approval = asRecord(run.pending_approval)
+            const pending =
+              confirmation?.status === "pending" ? confirmation : approval?.status === "pending" ? approval : undefined
+            if (!pending || !getString(pending.approval_id)) return
+            const expires = getString(pending.expires_at)
+            const remaining = expires ? Date.parse(expires) - Date.now() : NaN
+            const phase = remaining <= 0 ? "expired" : remaining < 60000 ? "due" : "pending"
+            const marker = `${identity}:${pending.approval_id}:${phase}`
+            if (seen.has(marker)) return
+            await notify(
+              `${row.agent_name} · ${row.run_id}\n${confirmation === pending ? "工具确认" : "人工审阅"}${phase === "expired" ? "已到截止时间,请查询最新状态" : phase === "due" ? "即将到期" : "等待你的决定"}。${expires ? `截止 ${expires}。` : "未指定截止时间。"}用 /paas-status 查看材料;不会自动批准。`,
+            )
+            seen.add(marker)
+          }),
+        )
+      }
+    } finally {
+      busy = false
+    }
+  }
+  const timer = setInterval(() => {
+    void poll().catch(() => undefined)
+  }, 20000)
+  timer.unref()
+  return {
+    poll,
+    dispose() {
+      clearInterval(timer)
+      abort.abort()
+    },
+  }
+}

+ 147 - 0
.opencode/lib/recovery.ts

@@ -0,0 +1,147 @@
+import { mkdir, readdir, rename, rm } from "node:fs/promises"
+import path from "node:path"
+import { setTimeout } from "node:timers/promises"
+import {
+  agentPaaSConversationDir,
+  asRecord,
+  canonicalJSON,
+  digest,
+  getString,
+  requestJSON,
+  trackRun,
+  type Connection,
+} from "./agentpaas"
+
+function directory(config: Connection) {
+  return path.join(agentPaaSConversationDir(), "recovery", digest(canonicalJSON([config.url, config.apiKey])))
+}
+function filename(config: Connection, kind: string, agentID: string, id: string) {
+  return path.join(directory(config), `${kind}-${digest(canonicalJSON([agentID, id]))}.json`)
+}
+async function write(file: string, value: unknown) {
+  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
+  const temporary = `${file}.${crypto.randomUUID()}.tmp`
+  try {
+    await Bun.write(temporary, JSON.stringify(value), { mode: 0o600 })
+    await rename(temporary, file)
+  } finally {
+    await rm(temporary, { force: true })
+  }
+}
+
+export async function pendingRequest(config: Connection, agentID: string, key: string) {
+  await write(filename(config, "request", agentID, key), { agent_id: agentID, idempotency_key: key })
+}
+export async function clearPendingRequest(config: Connection, agentID: string, key: string) {
+  await rm(filename(config, "request", agentID, key), { force: true })
+}
+export async function recoverRequest(config: Connection, agentID: string, key: string, signal: AbortSignal) {
+  const receipt = await requestJSON(
+    config,
+    `/agents/${encodeURIComponent(agentID)}/requests/${encodeURIComponent(key)}`,
+    signal,
+  )
+  const run = asRecord(receipt.run)
+  if (receipt.found !== true || !getString(run?.run_id)) return undefined
+  await trackRun(config, {
+    agent_id: agentID,
+    agent_name: agentID,
+    run_id: getString(run!.run_id),
+    thread_id: getString(run!.session_id) || getString(run!.thread_id),
+  })
+  await clearPendingRequest(config, agentID, key)
+  return run
+}
+export async function recoverPendingRequests(config: Connection, signal: AbortSignal) {
+  const files = await readdir(directory(config)).catch((error: NodeJS.ErrnoException) => {
+    if (error.code === "ENOENT") return []
+    throw error
+  })
+  const warnings: string[] = []
+  // Bound each refresh; keep unresolved receipts for a later read-only retry.
+  for (const file of files.filter((file) => /^request-[a-f0-9]{64}\.json$/.test(file)).slice(0, 20)) {
+    const row = asRecord(
+      await Bun.file(path.join(directory(config), file))
+        .json()
+        .catch(() => undefined),
+    )
+    if (!row) {
+      warnings.push("提交凭据损坏,请查询服务端历史。")
+      continue
+    }
+    const agentID = getString(row.agent_id),
+      key = getString(row.idempotency_key)
+    try {
+      if (!(await recoverRequest(config, agentID, key, signal)))
+        warnings.push(`${agentID} 的提交尚未查到,仍保留凭据;不要换键重跑。idempotency_key=${key}`)
+    } catch (error) {
+      if (signal.aborted) throw error
+      warnings.push(`提交结果暂不可核查:${agentID};${String(error)}`)
+    }
+  }
+  return warnings
+}
+
+export async function observation(config: Connection, agentID: string, runID: string) {
+  const file = Bun.file(filename(config, "run", agentID, runID))
+  return (await file.exists()) ? (asRecord(await file.json()) ?? {}) : {}
+}
+export async function saveObservation(
+  config: Connection,
+  agentID: string,
+  runID: string,
+  update: Record<string, unknown>,
+) {
+  await write(filename(config, "run", agentID, runID), { ...(await observation(config, agentID, runID)), ...update })
+}
+
+export async function cancelAndObserve(config: Connection, agentID: string, runID: string) {
+  const base = `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}`
+  let persistenceWarning = ""
+  const persist = async (state: string) => {
+    await saveObservation(config, agentID, runID, { cancellation_status: state }).catch(() => {
+      persistenceWarning = "本地取消记录未保存;请使用 run_id 查询服务端状态。"
+    })
+  }
+  await persist("requested")
+  const signal = AbortSignal.timeout(6000)
+  let error = ""
+  await requestJSON(config, `${base}/cancel`, signal, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: "{}",
+  }).catch((cause) => {
+    error = String(cause)
+  })
+  let snapshot: Record<string, unknown> = {}
+  for (let attempt = 0; attempt < 3 && !signal.aborted; attempt++) {
+    snapshot = await requestJSON(config, base, signal).catch(() => snapshot)
+    if (["cancelled", "completed", "partial", "failed"].includes(getString(snapshot.status))) break
+    await setTimeout(300 * (attempt + 1), undefined, { signal }).catch(() => undefined)
+  }
+  const state =
+    snapshot.status === "cancelled"
+      ? "confirmed"
+      : ["completed", "partial", "failed"].includes(getString(snapshot.status))
+        ? "already_terminal"
+        : error
+          ? "unconfirmed"
+          : "requested"
+  await persist(state)
+  return {
+    ...snapshot,
+    run_id: runID,
+    status: snapshot.status || "unknown",
+    cancellation_status: state,
+    ...(persistenceWarning ? { persistence_warning: persistenceWarning } : {}),
+  }
+}
+
+export function cancellationStatus(saved: Record<string, unknown>, status: unknown) {
+  if (!saved.cancellation_status) return undefined
+  return status === "cancelled"
+    ? "confirmed"
+    : ["completed", "partial", "failed"].includes(getString(status))
+      ? "already_terminal"
+      : saved.cancellation_status
+}

+ 24 - 0
.opencode/plugin/agentpaas-notify.ts

@@ -0,0 +1,24 @@
+import type { Plugin } from "@opencode-ai/plugin"
+import { loadAgentPaaSConfig } from "../lib/agentpaas"
+import { approvalMonitor } from "../lib/approval-monitor"
+
+const plugin: Plugin = async ({ client }) => {
+  const config = await loadAgentPaaSConfig().catch(() => undefined)
+  if (!config) return {}
+  const monitor = approvalMonitor(config, async (message) => {
+    const response = await client.tui.showToast({
+      body: { title: "PaaS 待审批", message, variant: "warning", duration: 10000 },
+    })
+    if (response.error) throw new Error("提醒未投递")
+  })
+  void monitor.poll().catch(() => undefined)
+  return {
+    async dispose() {
+      monitor.dispose()
+    },
+    async "tool.execute.after"(input) {
+      if (input.tool.startsWith("agentpaas_")) void monitor.poll().catch(() => undefined)
+    },
+  }
+}
+export default plugin

+ 530 - 0
.opencode/tool/agentpaas.ts

@@ -0,0 +1,530 @@
+import { tool } from "@opencode-ai/plugin"
+import {
+  recoverPendingRequests,
+  recoverRequest,
+  observation,
+  saveObservation,
+  cancelAndObserve,
+  cancellationStatus,
+} from "../lib/recovery"
+import { mkdir, open, realpath, stat } from "node:fs/promises"
+import path from "node:path"
+import { setTimeout } from "node:timers/promises"
+import { pathToFileURL } from "node:url"
+import {
+  asRecord,
+  digest,
+  formatRunResult,
+  getString,
+  isSupportedAgentID,
+  listAgents,
+  loadAgentPaaSConfig,
+  loadConversationState,
+  saveConversationState,
+  requestJSON,
+  resolveAgentID,
+  verifyAgent,
+  trackRun,
+  trackedRuns,
+} from "../lib/agentpaas"
+
+const selector = {
+  agent_id: tool.schema.string().optional().describe("目录返回的智能体 ID,优先使用"),
+  agent_name: tool.schema.string().optional().describe("智能体名称;同名时必须用 ID"),
+}
+
+export const agents = tool({
+  description: "检查 PaaS 连接并列出当前账号可用的全部智能体、用途和本地/远端运行位置。不执行任务。",
+  args: {},
+  async execute(_args, context) {
+    const config = await loadAgentPaaSConfig()
+    const catalog = await listAgents(config, context.abort)
+    return {
+      title: "PaaS 智能体目录",
+      output: [
+        `已连接 ${config.url};发现状态:${getString(catalog.discovery.status)}`,
+        ...catalog.agents.map(
+          (agent) =>
+            `${agent.name} | ${agent.id} | ${agent.runtime_mode ?? "unknown"} | ${agent.status ?? "unknown"}\n${getString(agent.description)}`,
+        ),
+        ...(!catalog.agents.length ? ["当前列表为空;远端发现未完成或不可用时,不能据此判断平台没有智能体。"] : []),
+      ].join("\n"),
+      metadata: { server: config.url, ...catalog },
+    }
+  },
+})
+
+export const check = tool({
+  description:
+    "检查智能体的执行服务、skill、MCP 与知识绑定。不创建研究任务。默认不调用模型;check_model=true 会产生一次或多次小额模型请求,knowledge_query 可验证检索。未验证不等于不可用。",
+  args: {
+    ...selector,
+    check_model: tool.schema.boolean().optional(),
+    knowledge_query: tool.schema.string().max(500).optional(),
+  },
+  async execute(args, context) {
+    const config = await loadAgentPaaSConfig()
+    const agentID = await resolveAgentID(config, args.agent_id || args.agent_name || "", context.abort)
+    const report = await verifyAgent(config, agentID, context.abort, {
+      check_model: args.check_model ?? false,
+      knowledge_query: args.knowledge_query ?? "",
+    })
+    return { title: "PaaS 能力检查", output: report.summary, metadata: { agent_id: agentID, preflight: report } }
+  },
+})
+
+export const tasks = tool({
+  description:
+    "找回本机此 PaaS 账号登记过的最近任务,跨 OpenCode 会话刷新服务端真实状态。只观察,不重跑、不取消、不自动批准。未登记的更早任务用 task/history 查询。",
+  args: { limit: tool.schema.number().int().min(1).max(50).optional() },
+  async execute(args, context) {
+    const config = await loadAgentPaaSConfig()
+    const warnings = await recoverPendingRequests(config, context.abort)
+    const saved = await trackedRuns(config, args.limit ?? 20)
+    const results: Array<ReturnType<typeof formatRunResult>> = []
+    // Bound concurrency so reopening a client cannot flood the control plane.
+    for (let offset = 0; offset < saved.length; offset += 5) {
+      results.push(
+        ...(await Promise.all(
+          saved.slice(offset, offset + 5).map(async (row) => {
+            const snapshot = await requestJSON(
+              config,
+              `/agents/${encodeURIComponent(row.agent_id)}/runs/${encodeURIComponent(row.run_id)}`,
+              context.abort,
+            ).catch((error): Record<string, unknown> => {
+              if (context.abort.aborted) throw error
+              return {
+                status: "unknown",
+                lookup_warning: `状态读取失败:${String(error)};已保留任务编号,不会重新执行。`,
+              }
+            })
+            // A task list should not copy every research report or approval payload
+            // into the model context. Read full materials through task/status.
+            const confirmation = asRecord(snapshot.pending_confirmation)
+            const local = await observation(config, row.agent_id, row.run_id)
+            const count = Array.isArray(snapshot.artifacts) ? snapshot.artifacts.length : 0
+            return formatRunResult(row.agent_name, {
+              agent_id: row.agent_id,
+              run_id: row.run_id,
+              status: snapshot.status,
+              output:
+                getString(snapshot.lookup_warning) ||
+                `已登记产物:${count}。完整结果和审批材料请用 agentpaas_task status 查看。`,
+              pending_confirmation: confirmation
+                ? {
+                    status: confirmation.status,
+                    approval_id: confirmation.approval_id,
+                    expires_at: confirmation.expires_at,
+                  }
+                : undefined,
+              pending_approval: snapshot.pending_approval
+                ? {
+                    approval_id: asRecord(snapshot.pending_approval)?.approval_id,
+                    expires_at: asRecord(snapshot.pending_approval)?.expires_at,
+                  }
+                : undefined,
+              cancellation_status: cancellationStatus(local, snapshot.status),
+              usage: snapshot.usage,
+              checkpoint_id: snapshot.checkpoint_id,
+              artifact_count: count,
+              total_tokens: snapshot.total_tokens ?? asRecord(snapshot.usage)?.total_tokens,
+              cost_usd: snapshot.cost_usd ?? asRecord(snapshot.usage)?.cost_usd,
+            })
+          }),
+        )),
+      )
+    }
+    return {
+      title: "PaaS 最近任务",
+      output: results.length
+        ? [results.map((result) => `${result.title}\n${result.output}`).join("\n\n"), ...warnings].join("\n")
+        : ["此账号尚无本机任务索引;可先用 agentpaas_task history 查询指定智能体的服务端历史。", ...warnings].join(
+            "\n",
+          ),
+      metadata: {
+        runs: results.map((result) => result.metadata),
+        scope: "locally_tracked_runs",
+        limit: args.limit ?? 20,
+        warnings,
+      },
+    }
+  },
+})
+
+export const task = tool({
+  description:
+    "查询、跟踪或控制已有 PaaS 任务,不创建新任务。history 查看历史;select 选中历史任务作为当前对话的后续上下文;status 查询;watch 恢复进度;pause/resume/cancel 控制原任务;approve/reject 处理审批。",
+  args: {
+    ...selector,
+    action: tool.schema.enum([
+      "history",
+      "recover",
+      "select",
+      "status",
+      "watch",
+      "pause",
+      "resume",
+      "cancel",
+      "approve",
+      "reject",
+    ]),
+    run_id: tool.schema.string().optional().describe("省略时使用本 OpenCode 会话中该智能体的最近任务"),
+    idempotency_key: tool.schema.string().optional().describe("recover 按原提交凭据找回任务,不重新提交"),
+    checkpoint_id: tool.schema.string().optional(),
+    approval_id: tool.schema.string().optional(),
+    note: tool.schema.string().optional().describe("用户的审批意见,原样记录"),
+    after: tool.schema.number().int().min(0).optional().describe("事件游标;省略时使用本机已保存进度,显式 0 重放"),
+    wait_seconds: tool.schema
+      .number()
+      .int()
+      .min(0)
+      .max(120)
+      .optional()
+      .describe("watch 最长观察时间,默认 30 秒;到期不取消任务"),
+  },
+  async execute(args, context) {
+    const config = await loadAgentPaaSConfig()
+    const agentID = args.agent_id || (await resolveAgentID(config, args.agent_name || "", context.abort))
+    if (!isSupportedAgentID(agentID)) throw new Error("无效 agent_id")
+    const base = `/agents/${encodeURIComponent(agentID)}/runs`
+    if (args.action === "recover") {
+      if (!args.idempotency_key) throw new Error("recover 需要原 idempotency_key")
+      const found = await recoverRequest(config, agentID, args.idempotency_key, context.abort)
+      if (!found)
+        return {
+          title: "受理结果尚未确认",
+          output: "尚未查到此提交;不能据此断定未受理。稍后再次 recover,不要换键重跑。",
+          metadata: { agent_id: agentID, admission_status: "unknown" },
+        }
+      return formatRunResult(args.agent_name || agentID, found, { agent_id: agentID, admission_recovered: true })
+    }
+    if (args.action === "history") {
+      const history = await requestJSON(config, base, context.abort)
+      return {
+        title: "PaaS 任务历史",
+        output: JSON.stringify(history, null, 2),
+        metadata: { agent_id: agentID, ...history },
+      }
+    }
+    const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
+    const saved = await loadConversationState(context.sessionID, conversationKey)
+    if (args.action === "select" && !args.run_id) throw new Error("select 必须明确指定要继续的 run_id")
+    const runID = args.run_id || saved?.runID
+    if (!runID) throw new Error("没有最近任务,请先查询 history 并选择 run_id;不会自动提交新任务。")
+    const route = `${base}/${encodeURIComponent(runID)}`
+    if (args.action === "cancel") {
+      const result = await cancelAndObserve(config, agentID, runID)
+      return formatRunResult(args.agent_name || agentID, result, { agent_id: agentID })
+    }
+    let snapshot = await requestJSON(config, route, context.abort)
+    const local = await observation(config, agentID, runID)
+    snapshot.cancellation_status = cancellationStatus(local, snapshot.status)
+    if (args.action === "select") {
+      const threadID = getString(snapshot.thread_id) || getString(snapshot.session_id)
+      if (!threadID) throw new Error("此历史任务没有可恢复的会话 ID,未改变当前对话")
+      await saveConversationState(context.sessionID, conversationKey, { threadID, runID }, true)
+      const trackingWarning = await trackRun(config, {
+        agent_id: agentID,
+        agent_name: args.agent_name || agentID,
+        run_id: runID,
+        thread_id: threadID,
+      }).then(
+        () => "",
+        () => "最近任务索引保存失败,请保留此 run_id;当前对话选择已保存。\n",
+      )
+      const result = formatRunResult(args.agent_name || agentID, snapshot, {
+        agent_id: agentID,
+        run_id: runID,
+        selected: true,
+      })
+      return {
+        ...result,
+        output: `已选中此任务,后续修订将沿用会话和父版本。运行中先 watch,暂停用 resume,等待确认先处理审批。\n${trackingWarning}${result.output}`,
+      }
+    }
+    if (["pause", "resume", "cancel", "approve", "reject"].includes(args.action)) {
+      const approval = asRecord(snapshot.pending_approval)
+      const confirmation = asRecord(snapshot.pending_confirmation)
+      const checkpoint = args.checkpoint_id || getString(snapshot.checkpoint_id)
+      if (args.action === "resume" && (snapshot.status !== "paused" || !checkpoint))
+        throw new Error("原任务未暂停或缺少 checkpoint_id,不能通过新建任务代替恢复。")
+      if (args.action === "resume" && snapshot.checkpoint_id && checkpoint !== snapshot.checkpoint_id)
+        throw new Error("checkpoint_id 已过期,请查询原任务。")
+      const confirming = args.action === "approve" || args.action === "reject"
+      const currentApprovalID = getString(approval?.approval_id) || getString(confirmation?.approval_id)
+      const approvalID = args.approval_id || currentApprovalID
+      if (confirming) {
+        if (
+          !["waiting_human", "waiting_confirmation"].includes(getString(snapshot.status)) &&
+          confirmation?.status !== "pending"
+        )
+          throw new Error("原任务当前没有等待确认;请检查状态,不会自动批准或重跑。")
+        if (!currentApprovalID) throw new Error("缺少 approval_id,请升级执行服务并查询最新审批材料。")
+        if (approvalID !== currentApprovalID) throw new Error("approval_id 已过期,请查询最新审批材料。")
+        if (args.action === "approve")
+          await context.ask({
+            permission: "agentpaas_confirm",
+            patterns: [`${agentID}:${runID}:${approvalID}`],
+            always: [],
+            metadata: {
+              agent_id: agentID,
+              run_id: runID,
+              approval_id: approvalID,
+              pending_approval: approval,
+              pending_confirmation: confirmation,
+              note: args.note ?? "",
+            },
+          })
+      }
+      const body = confirming
+        ? {
+            approved: args.action === "approve",
+            approval_id: approvalID,
+            note: args.note ?? "",
+            idempotency_key: `opencode-control-${digest(JSON.stringify([config.url, digest(config.apiKey), agentID, runID, approvalID, args.action, args.note ?? ""]))}`,
+          }
+        : args.action === "resume"
+          ? { checkpoint_id: checkpoint }
+          : {}
+      // Mutations are sent once. An ambiguous response must be resolved by status.
+      await requestJSON(config, `${route}/${confirming ? "confirm" : args.action}`, context.abort, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      })
+      snapshot = await requestJSON(config, route, context.abort)
+    }
+    if (args.action !== "watch")
+      return formatRunResult(args.agent_name || agentID, snapshot, { agent_id: agentID, run_id: runID })
+    let cursor = args.after ?? (typeof local.event_cursor === "number" ? local.event_cursor : 0)
+    const warnings: string[] = []
+    const stages = new Map<string, Record<string, unknown>>()
+    if (args.after === undefined && Array.isArray(local.stages))
+      for (const item of local.stages) {
+        const row = asRecord(item)
+        if (row && getString(row.stage_id)) stages.set(getString(row.stage_id), row)
+      }
+    let idle = 0
+    const deadline = Date.now() + (args.action === "watch" ? (args.wait_seconds ?? 30) * 1000 : 0)
+    do {
+      const before = cursor
+      const page = await requestJSON(config, `${route}/events?after=${cursor}&source=service`, context.abort).catch(
+        (error) => {
+          if (context.abort.aborted) throw error
+          warnings.push(`事件读取暂不可用,已保留任务快照与产物:${String(error)}`)
+          return { events: [] }
+        },
+      )
+      for (const item of Array.isArray(page.events) ? page.events : []) {
+        const event = asRecord(item)
+        if (!event || typeof event.sequence !== "number" || event.sequence <= cursor) continue
+        cursor = event.sequence
+        const data = asRecord(event.data)
+        const stageID = getString(data?.stage_id)
+        const kind = getString(event.event_type) || getString(event.event)
+        if (stageID && ["stage_started", "stage_passed", "stage_completed", "stage_failed"].includes(kind))
+          stages.set(stageID, {
+            ...data,
+            status: kind === "stage_started" ? "running" : kind === "stage_failed" ? "failed" : "completed",
+          })
+      }
+      snapshot = await requestJSON(config, route, context.abort).catch((error) => {
+        if (context.abort.aborted) throw error
+        warnings.push(`状态刷新暂不可用,以下为最近一次快照:${String(error)}`)
+        return snapshot
+      })
+      idle = cursor === before ? idle + 1 : 0
+      snapshot.cancellation_status = cancellationStatus(local, snapshot.status)
+      await saveObservation(config, agentID, runID, { event_cursor: cursor, stages: [...stages.values()] }).catch(
+        () => {
+          warnings.push("进度游标保存失败,重新打开时可能重放旧事件;任务不会重跑。")
+        },
+      )
+      context.metadata({
+        title: `PaaS ${agentID} · ${snapshot.status}`,
+        metadata: {
+          agent_id: agentID,
+          run_id: runID,
+          status: snapshot.status,
+          event_cursor: cursor,
+          stages: [...stages.values()],
+        },
+      })
+      if (
+        warnings.length ||
+        asRecord(snapshot.pending_confirmation)?.status === "pending" ||
+        !["queued", "running", "resuming"].includes(getString(snapshot.status)) ||
+        Date.now() >= deadline
+      )
+        break
+      // Aborting observation never cancels the worker. cancel is a separate action.
+      await setTimeout(
+        Math.min(Math.min(500 * 2 ** Math.min(idle, 4), 5000), Math.max(0, deadline - Date.now())),
+        undefined,
+        { signal: context.abort },
+      )
+    } while (Date.now() <= deadline)
+    const result = formatRunResult(args.agent_name || agentID, snapshot, {
+      agent_id: agentID,
+      run_id: runID,
+      event_cursor: cursor,
+      stages: [...stages.values()],
+      warnings,
+    })
+    return { ...result, output: [result.output, ...warnings].join("\n") }
+  },
+})
+
+const MAX_FILE_BYTES = 256 * 1024 * 1024
+
+export const upload = tool({
+  description:
+    "把用户指定的本机文件上传到 PaaS,返回 file_id。随后将这些 ID 传入 agentpaas_run.input_file_ids。仅上传用户明确选择的文件。",
+  args: { paths: tool.schema.array(tool.schema.string()).min(1).max(20) },
+  async execute(args, context) {
+    const config = await loadAgentPaaSConfig()
+    const capabilities = await requestJSON(config, "/files/capabilities", context.abort)
+    const serverLimit = capabilities.direct_upload_max_bytes
+    if (typeof serverLimit !== "number" || !Number.isSafeInteger(serverLimit) || serverLimit < 0)
+      throw new Error("PaaS 未返回有效上传限制,请更新 PaaS 后再上传")
+    const limit = Math.min(MAX_FILE_BYTES, serverLimit)
+    const paths = await Promise.all(args.paths.map((name) => realpath(path.resolve(context.directory, name))))
+    for (const filename of paths) {
+      const info = await stat(filename)
+      if (!info.isFile() || info.size > limit) throw new Error(`文件不可上传或超过当前上限 ${limit} 字节:${filename}`)
+    }
+    await context.ask({
+      permission: "agentpaas_upload",
+      patterns: paths,
+      always: [],
+      metadata: { server: config.url, paths },
+    })
+    const files: Record<string, unknown>[] = []
+    for (const filename of paths) {
+      const file = Bun.file(filename)
+      const bytes = await file.arrayBuffer()
+      if (bytes.byteLength > limit) throw new Error(`文件超过当前上限 ${limit} 字节:${filename}`)
+      const query = new URLSearchParams({
+        name: path.basename(filename),
+        mime_type: file.type || "application/octet-stream",
+      })
+      const result = await requestJSON(config, `/files?${query}`, context.abort, {
+        method: "POST",
+        headers: {
+          "Content-Type": file.type || "application/octet-stream",
+          "X-File-SHA256": new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),
+        },
+        body: bytes,
+      })
+      if (!getString(result.file_id) || result.status !== "available") throw new Error("PaaS 未返回可用的 file_id")
+      files.push({ file_id: result.file_id, name: result.name, size_bytes: result.size_bytes })
+      context.metadata({ title: `已上传 ${files.length}/${paths.length} 个文件`, metadata: { files: [...files] } })
+    }
+    return { title: "输入文件已上传", output: JSON.stringify(files, null, 2), metadata: { files } }
+  },
+})
+
+export const download = tool({
+  description:
+    "通过 PaaS 身份验证下载已有任务的产物到本机,校验大小/摘要并返回可打开的文件。不会读取服务端文件路径,也不会覆盖已有文件。",
+  args: {
+    ...selector,
+    run_id: tool.schema.string(),
+    artifact_id: tool.schema.string(),
+    output_path: tool.schema.string().optional().describe("本机保存路径;省略时放入当前项目 .agentpaas-artifacts 目录"),
+  },
+  async execute(args, context) {
+    const config = await loadAgentPaaSConfig()
+    const agentID = args.agent_id || (await resolveAgentID(config, args.agent_name || "", context.abort))
+    if (!isSupportedAgentID(agentID)) throw new Error("无效 agent_id")
+    const route = `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(args.run_id)}/artifacts`
+    const manifest = await requestJSON(config, route, context.abort)
+    const artifact = (Array.isArray(manifest.artifacts) ? manifest.artifacts : [])
+      .map(asRecord)
+      .find((item) => item?.artifact_id === args.artifact_id)
+    if (!artifact) throw new Error("当前任务没有该产物")
+    const name =
+      path.basename(getString(artifact.name).replaceAll("\\", "/")).replace(/[<>:\x00-\x1f]/g, "_") || "artifact"
+    const base = await realpath(context.directory)
+    const destination = args.output_path
+      ? path.resolve(base, args.output_path)
+      : path.join(
+          base,
+          ".agentpaas-artifacts",
+          digest(`${config.url}:${agentID}:${args.run_id}`).slice(0, 24),
+          `${digest(args.artifact_id).slice(0, 12)}-${name}`,
+        )
+    await context.ask({
+      permission: "agentpaas_download",
+      patterns: [destination],
+      always: [],
+      metadata: {
+        server: config.url,
+        agent_id: agentID,
+        run_id: args.run_id,
+        artifact_id: args.artifact_id,
+        destination,
+      },
+    })
+    const response = await fetch(`${config.url}/api/v1${route}/${encodeURIComponent(args.artifact_id)}/download`, {
+      headers: { Authorization: `Bearer ${config.apiKey}` },
+      redirect: "error",
+      signal: AbortSignal.any([context.abort, AbortSignal.timeout(60000)]),
+    })
+    if (!response.ok) throw new Error(`产物下载失败:HTTP ${response.status}`)
+    if (Number(response.headers.get("Content-Length")) > MAX_FILE_BYTES) {
+      await response.body?.cancel()
+      throw new Error("产物超过 256 MiB,请通过平台文件传输接口获取")
+    }
+    const chunks: Uint8Array[] = []
+    let size = 0
+    const reader = response.body?.getReader()
+    if (!reader) throw new Error("产物下载没有响应内容")
+    try {
+      while (true) {
+        const chunk = await reader.read()
+        if (chunk.done) break
+        size += chunk.value.length
+        if (size > MAX_FILE_BYTES) throw new Error("产物超过 256 MiB")
+        chunks.push(chunk.value)
+      }
+    } finally {
+      await reader.cancel().catch(() => undefined)
+      reader.releaseLock()
+    }
+    const content = Buffer.concat(chunks)
+    const hash = new Bun.CryptoHasher("sha256").update(content).digest("hex")
+    if (artifact.sha256 && artifact.sha256 !== hash) throw new Error("产物摘要不匹配,未写入本机")
+    if (typeof artifact.size_bytes === "number" && artifact.size_bytes > 0 && artifact.size_bytes !== size)
+      throw new Error("产物大小不匹配,未写入本机")
+    await mkdir(path.dirname(destination), { recursive: true })
+    const parent = await realpath(path.dirname(destination))
+    if (!args.output_path && !parent.startsWith(base + path.sep)) throw new Error("默认产物目录指向项目之外,拒绝写入")
+    const handle = await open(path.join(parent, path.basename(destination)), "wx", 0o600)
+    try {
+      await handle.writeFile(content)
+    } finally {
+      await handle.close()
+    }
+    return {
+      title: "产物已下载",
+      output: `[${name.replace(/[\[\]]/g, "_")}](<${destination}>)`,
+      metadata: {
+        agent_id: agentID,
+        run_id: args.run_id,
+        artifact_id: args.artifact_id,
+        local_path: destination,
+        size_bytes: size,
+        sha256: hash,
+      },
+      attachments: [
+        {
+          type: "file" as const,
+          filename: name,
+          mime: getString(artifact.mime_type) || "application/octet-stream",
+          url: pathToFileURL(destination).href,
+        },
+      ],
+    }
+  },
+})

+ 194 - 283
.opencode/tool/agentpaas_run.ts

@@ -1,14 +1,22 @@
 /// <reference path="../env.d.ts" />
 import { tool } from "@opencode-ai/plugin"
-import { link, mkdir, rm } from "node:fs/promises"
-import { homedir } from "node:os"
-import path from "node:path"
-
-const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
+import { pendingRequest, clearPendingRequest, recoverRequest, cancelAndObserve } from "../lib/recovery"
+import {
+  loadAgentPaaSConfig,
+  resolveAgentID,
+  digest,
+  canonicalJSON,
+  requestContext,
+  loadConversationState,
+  saveConversationState,
+  formatRunResult,
+  scientificLabel,
+  requestJSON,
+  verifyAgent,
+  trackRun,
+} from "../lib/agentpaas"
 
 type SSEEvent = { event: string; data: unknown }
-type AgentListItem = { id: string; name: string }
-type ConversationState = { threadID: string; runID: string }
 type ProgressStatus = "completed" | "running" | "pending" | "failed" | "paused" | "cancelled" | "skipped" | "unknown"
 type ProgressMilestone = {
   id: string
@@ -26,11 +34,12 @@ type ProgressStage = {
 }
 
 export default tool({
-  description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
+  description: `把任务转发给 PaaS 目录中的智能体执行,返回真实运行状态、答案和产物。
 
 参数说明:
-- agent_name:远端 AgentPaaS 智能体名称,工具会动态解析当前 ID,必填
+- agent_id / agent_name:至少填写一个;优先使用目录返回的 ID,同名时必须使用 ID
 - input:要执行的用户任务指令
+- input_file_ids:已通过 agentpaas_upload 上传的文件 ID,不接受客户端路径
 - mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
 - target_subagent:mode=edit 时的目标子智能体 ID
 - work_dir:可选的服务端可见工作目录;留空时由 AgentPaaS 创建隔离 workspace
@@ -38,10 +47,20 @@ export default tool({
 - thread_id:可选的远端会话线程 ID;显式值优先于自动映射
 - parameters:额外参数,原样传给远端智能体
 
-适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
+适用于 PaaS 目录中的本地或远端智能体,包括科研、数据分析、软件工程和问答等已注册能力。
+查询、继续观察或控制已有任务请用 agentpaas_task,不要再次提交。`,
   args: {
     input: tool.schema.string().describe("用户任务指令"),
-    agent_name: tool.schema.string().describe("远端 AgentPaaS 智能体名称"),
+    background: tool.schema
+      .boolean()
+      .optional()
+      .describe("长任务设为 true:受理并保存编号后返回,在服务端继续执行;用 task/watch 查看,用 cancel 停止"),
+    agent_name: tool.schema.string().optional().describe("远端 AgentPaaS 智能体名称;与 agent_id 至少填写一个"),
+    agent_id: tool.schema.string().optional().describe("目录返回的稳定 ID;同名智能体用 ID 选择"),
+    input_file_ids: tool.schema
+      .array(tool.schema.string())
+      .optional()
+      .describe("agentpaas_upload 返回的 file_id;不接受客户端路径"),
     mode: tool.schema.enum(["iterate", "chat", "edit"]).default("iterate"),
     target_subagent: tool.schema.string().optional().describe("mode=edit 时的目标子智能 ID"),
     work_dir: tool.schema.string().optional().describe("服务端可见工作目录;通常留空,由 AgentPaaS 创建"),
@@ -54,6 +73,12 @@ export default tool({
       .optional()
       .describe("续跑时选取的上一轮产物 ID;省略时复用上一轮已登记产物"),
     idempotency_key: tool.schema.string().optional().describe("同一请求重试时复用;不同请求必须使用不同键"),
+    max_cost_usd: tool.schema
+      .number()
+      .positive()
+      .finite()
+      .optional()
+      .describe("仅支持预检查声明可用的 CEK 单智能体;按服务端计量在调用结算后检查,非供应商硬限额"),
     parameters: tool.schema
       .record(tool.schema.string(), tool.schema.any())
       .optional()
@@ -64,7 +89,13 @@ export default tool({
     if (args.mode === "edit" && !args.target_subagent) {
       throw new Error("mode=edit 时必须提供 target_subagent")
     }
-    const agentID = await resolveAgentID(config, args.agent_name, context.abort)
+    const agentID = await resolveAgentID(config, args.agent_id || args.agent_name || "", context.abort)
+    const agentName = args.agent_name || agentID
+    context.metadata({ title: `PaaS ${agentName} · 执行前检查`, metadata: { agent_id: agentID, status: "checking" } })
+    const preflight = await verifyAgent(config, agentID, context.abort, undefined, true)
+    if (preflight.blocked) throw new Error(preflight.summary)
+    if (args.max_cost_usd !== undefined && preflight.client_budget_supported !== true)
+      throw new Error("此智能体未声明支持客户端费用预算,任务未提交;不能承诺限额会生效。")
     const conversation = args.conversation ?? "continue"
     const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
     const savedConversation =
@@ -74,18 +105,37 @@ export default tool({
       conversation === "continue" && (!args.thread_id?.trim() || args.thread_id.trim() === savedConversation?.threadID)
         ? savedConversation?.runID || ""
         : ""
-    const intent = digest(canonicalJSON({ ...args, idempotency_key: undefined, conversation }))
+    // Observation mode does not change the admitted business task.
+    const intent = digest(canonicalJSON({ ...args, background: undefined, idempotency_key: undefined, conversation }))
     const idempotencyKey =
       args.idempotency_key?.trim() ||
       `opencode-${digest(canonicalJSON([context.sessionID, context.messageID, agentID, intent]))}`
     // Freeze the parent before submitting. Once `started` advances the session,
     // retrying this same request must not turn it into a new research revision.
     const admission = await requestContext(config, agentID, idempotencyKey, { intent, threadID, runID: continueRunID })
+    const rememberRecovered = async (run: Record<string, unknown>) => {
+      const runID = getString(run.run_id),
+        remoteThreadID = getString(run.session_id) || getString(run.thread_id)
+      if (!runID || !remoteThreadID) return
+      const latest = await loadConversationState(context.sessionID, conversationKey)
+      if (!latest || latest.runID === admission.runID || latest.runID === runID)
+        await saveConversationState(context.sessionID, conversationKey, { threadID: remoteThreadID, runID })
+    }
+    const inputFiles = await Promise.all(
+      (args.input_file_ids ?? []).map(async (id) => {
+        const file = await requestJSON(config, `/files/${encodeURIComponent(id)}`, context.abort)
+        if (file.file_id !== id || file.status !== "available") throw new Error(`输入文件不可用:${id}`)
+        return file
+      }),
+    )
     // OpenCode 的宿主机目录不会自动挂载进 AgentPaaS Docker。只有调用方
     // 明确给出服务端可见路径时才覆盖,否则让 PaaS 创建并隔离 run workspace。
     const workDir = args.work_dir
     const url = `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/run/stream`
     let resp: Response
+    await pendingRequest(config, agentID, idempotencyKey)
+    const admissionAbort = new AbortController()
+    const admissionTimer = setTimeout(() => admissionAbort.abort(), 30000)
     try {
       resp = await fetch(url, {
         method: "POST",
@@ -97,10 +147,15 @@ export default tool({
         },
         body: JSON.stringify({
           input: args.input,
+          cancel_on_disconnect: false,
+          ...(inputFiles.length ? { input_files: inputFiles } : {}),
           mode: args.mode,
           target_subagent: args.target_subagent ?? "",
           thread_id: admission.threadID,
-          parameters: args.parameters ?? {},
+          parameters: {
+            ...args.parameters,
+            ...(args.max_cost_usd !== undefined ? { client_budget_usd: args.max_cost_usd } : {}),
+          },
           context: {
             ...(workDir ? { work_dir: workDir } : {}),
             ...(admission.runID ? { run_id: admission.runID } : {}),
@@ -109,49 +164,92 @@ export default tool({
             ...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
           },
         }),
-        signal: context.abort,
+        signal: AbortSignal.any([context.abort, admissionAbort.signal]),
+        redirect: "error",
       })
     } catch (err) {
-      throw new Error(`AgentPaaS 请求失败: ${err instanceof Error ? err.message : String(err)}`)
+      clearTimeout(admissionTimer)
+      if (!context.abort.aborted) {
+        const recovered = await recoverRequest(config, agentID, idempotencyKey, context.abort).catch(() => undefined)
+        if (recovered) {
+          await rememberRecovered(recovered).catch(() => undefined)
+          return formatRunResult(agentName, recovered, { agent_id: agentID, admission_recovered: true })
+        }
+      }
+      throw new Error(
+        `AgentPaaS 请求失败: ${err instanceof Error ? err.message : String(err)};idempotency_key=${idempotencyKey}。可能已受理,请查询原任务;不得换键自动重跑。`,
+      )
     }
     if (!resp.ok) {
+      clearTimeout(admissionTimer)
       const detail = await resp.text().catch(() => "")
       throw new Error(`AgentPaaS HTTP ${resp.status}: ${detail.slice(0, 500)}`)
     }
-    if (!resp.body) throw new Error("AgentPaaS 响应没有 body")
+    if (!resp.body) {
+      clearTimeout(admissionTimer)
+      throw new Error("AgentPaaS 响应没有 body")
+    }
 
     const errors: string[] = []
+    const warnings: string[] = []
     const liveMetadata: Record<string, unknown> = {
-      agent_name: args.agent_name,
+      agent_name: agentName,
       agent_id: agentID,
       idempotency_key: idempotencyKey,
       status: "running",
+      preflight,
     }
     let done: Record<string, unknown> | null = null
     let startedRunID = ""
     let sawCancelled = false
     let lastThinkUpdate = 0
     const progress = createResearch67Progress()
+    const researchUI = /research|科研/i.test(agentName) || context.agent.startsWith("research")
     const updateMetadata = (title: string, next: Record<string, unknown>) => {
       Object.assign(liveMetadata, next)
-      const snapshot = progress.snapshot()
+      const snapshot = researchUI ? progress.snapshot() : undefined
       context.metadata({
-        title: snapshot?.title ?? title,
+        title: snapshot?.title ?? (researchUI ? title : title.replaceAll("Research67", agentName)),
         metadata: { ...liveMetadata, ...(snapshot ? { research67_progress: snapshot } : {}) },
       })
     }
-    let remoteCancellation: Promise<void> | undefined
+    let remoteCancellation: Promise<unknown> | undefined
     const cancelOnAbort = () => {
+      if (args.background) return
       if (!startedRunID || remoteCancellation) return
-      remoteCancellation = cancelRemoteRun(config, agentID, startedRunID).catch(() => undefined)
+      remoteCancellation = cancelAndObserve(config, agentID, startedRunID).then(
+        (result) => {
+          updateMetadata("PaaS 取消状态", result)
+        },
+        (error) => {
+          updateMetadata("PaaS 取消未确认,请查询原任务", {
+            cancellation_status: "unconfirmed",
+            cancellation_error: String(error),
+          })
+        },
+      )
     }
     context.abort.addEventListener("abort", cancelOnAbort, { once: true })
     try {
       for await (const ev of parseSSE(resp.body)) {
         const data = asRecord(ev.data)
         if (ev.event === "started") {
+          clearTimeout(admissionTimer)
           startedRunID = getString(data?.run_id)
+          if (!startedRunID) throw new Error("PaaS started 事件缺少 run_id;不能确认任务受理情况")
           const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
+          await trackRun(config, {
+            agent_id: agentID,
+            agent_name: agentName,
+            run_id: startedRunID,
+            thread_id: remoteThreadID,
+          })
+            .then(() => clearPendingRequest(config, agentID, idempotencyKey))
+            .catch(() => {
+              warnings.push(
+                `本机任务索引写入失败,请保存 agent_id=${agentID}、run_id=${startedRunID},稍后用 status 查询。`,
+              )
+            })
           if (remoteThreadID && startedRunID) {
             const latest = await loadConversationState(context.sessionID, conversationKey)
             if (admission.created || !latest || latest.runID === admission.runID || latest.runID === startedRunID) {
@@ -167,6 +265,11 @@ export default tool({
             thread_id: data?.thread_id,
             latest_event: ev.event,
           })
+          if (args.background) {
+            // Breaking cancels only this reader. The worker owns the durable run.
+            done = { ...data, status: "queued", run_id: startedRunID }
+            break
+          }
         } else if (ev.event === "stage_started") {
           const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "未知阶段"
           progress.startStage(getString(data?.stage_id), getPositiveInteger(data?.attempt))
@@ -216,6 +319,17 @@ export default tool({
           const reason = getString(data?.reason)
           const input = getString(data?.input)
           if (!runID) throw new Error("AgentPaaS confirm_required 事件缺少 run_id")
+          const approvalID = getString(data?.approval_id)
+          if (!approvalID) throw new Error("PaaS 确认事件缺少 approval_id;请升级执行服务,不能批准未绑定的操作。")
+          // Replayed journals can contain already-resolved confirmations.
+          // Only ask about the operation that is still pending on the worker.
+          const current = await requestJSON(
+            config,
+            `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}`,
+            context.abort,
+          )
+          const pending = asRecord(current.pending_confirmation) || asRecord(current.pending_approval)
+          if (!pending || pending.approval_id !== approvalID || pending.status !== "pending") continue
 
           progress.note(`等待确认 · ${toolName}`)
           updateMetadata(`Research67 正在等待确认:${toolName}`, {
@@ -229,14 +343,21 @@ export default tool({
           try {
             await context.ask({
               permission: "agentpaas_confirm",
-              patterns: [`${agentID}:${toolName}`],
-              always: [`${agentID}:${toolName}`],
-              metadata: { run_id: runID, agent_name: args.agent_name, tool: toolName, reason, input },
+              patterns: [`${agentID}:${runID}:${approvalID}`],
+              always: [],
+              metadata: {
+                run_id: runID,
+                approval_id: approvalID,
+                agent_name: agentName,
+                tool: toolName,
+                reason,
+                input,
+              },
             })
           } catch {
             approved = false
           }
-          await resolveConfirmation(config, agentID, runID, approved, context.abort, getString(data?.approval_id))
+          await resolveConfirmation(config, agentID, runID, approved, context.abort, approvalID)
           progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
           updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
             latest_event: "confirmation_resolved",
@@ -312,8 +433,36 @@ export default tool({
           break
         }
       }
+    } catch (error) {
+      if (context.abort.aborted) throw error
+      errors.push(error instanceof Error ? error.message : String(error))
     } finally {
+      clearTimeout(admissionTimer)
       context.abort.removeEventListener("abort", cancelOnAbort)
+      if (remoteCancellation) await remoteCancellation
+    }
+    if (!done && !startedRunID && !context.abort.aborted) {
+      done = (await recoverRequest(config, agentID, idempotencyKey, context.abort).catch(() => undefined)) ?? null
+      if (done) {
+        liveMetadata.admission_recovered = true
+        await rememberRecovered(done).catch(() =>
+          warnings.push("任务已找回,会话映射保存失败;继续修订前请 select 此 run_id。"),
+        )
+      }
+    }
+    if ((!done || args.background) && startedRunID && !context.abort.aborted) {
+      // A dropped transport is not a failed run. Query, never resubmit.
+      const snapshot = await requestJSON(
+        config,
+        `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(startedRunID)}`,
+        context.abort,
+      ).catch(() => null)
+      if (snapshot) done = snapshot
+      if (snapshot && !args.background) liveMetadata.connection_recovered = true
+      if (!snapshot && args.background) {
+        done = { ...done, status: "unknown" }
+        warnings.push("任务已受理,最新状态暂不可读取;请用原 run_id 恢复查看,不要重复提交。")
+      }
     }
     if (!done) {
       const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
@@ -322,12 +471,12 @@ export default tool({
       )
     }
     const status = typeof done.status === "string" ? done.status : "unknown"
-    if (status === "completed" || status === "failed") progress.stopUnverified()
+    if (["completed", "partial", "needs_clarification", "failed"].includes(status)) progress.stopUnverified()
     if (status === "paused") progress.pause()
     if (status === "cancelled") progress.cancel()
-    const progressMetadata = progress.snapshot()
+    const progressMetadata = researchUI ? progress.snapshot() : undefined
     const metadata = {
-      agent_name: args.agent_name,
+      agent_name: agentName,
       agent_id: agentID,
       idempotency_key: idempotencyKey,
       run_id: done.run_id ?? liveMetadata.run_id,
@@ -341,254 +490,30 @@ export default tool({
       workspace_path: done.workspace_path ?? liveMetadata.workspace_path,
       error_code: liveMetadata.error_code,
       error_message: liveMetadata.error_message,
+      connection_recovered: liveMetadata.connection_recovered,
+      admission_recovered: liveMetadata.admission_recovered,
       checkpoint_id: done.checkpoint_id ?? liveMetadata.checkpoint_id,
       pending_approval: done.pending_approval ?? liveMetadata.pending_approval,
+      preflight,
+      background: args.background === true,
+      warnings,
       ...(progressMetadata ? { research67_progress: progressMetadata } : {}),
     }
-    const artifactLinks = metadata.artifacts.flatMap((value) => {
-      const artifact = asRecord(value)
-      const id = getString(artifact?.artifact_id)
-      if (!id) return []
-      const name = (getString(artifact?.name) || id).replace(/[\[\]\r\n]/g, "_")
-      return [
-        `- [${name}](${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(String(metadata.run_id))}/artifacts/${encodeURIComponent(id)}/download)`,
-      ]
-    })
-    const science = asRecord(asRecord(done.research)?.science)
-    if (status === "completed")
-      return {
-        title: science ? scientificLabel(science.status) : `AgentPaaS ${args.agent_name}`,
-        output: [
-          ...(science ? [`研究状态:${scientificLabel(science.status)}。执行结束不代表科学结论成立。\n`] : []),
-          String(done.output ?? ""),
-          ...(artifactLinks.length ? ["\n研究产物(通过 PaaS 身份验证后下载):", ...artifactLinks] : []),
-        ].join("\n"),
-        metadata,
-      }
-    if (status === "cancelled") return { title: `AgentPaaS ${args.agent_name}`, output: "任务已被取消。", metadata }
-    if (status === "paused")
-      return {
-        title: `AgentPaaS ${args.agent_name}(已暂停)`,
-        output: `任务已暂停。run_id=${String(metadata.run_id ?? "")};checkpoint_id=${String(metadata.checkpoint_id ?? "未提供")}。恢复应使用该 checkpoint,不能重新提交任务。`,
-        metadata,
-      }
-    if (status === "waiting_human" || status === "waiting_confirmation")
-      return {
-        title: `AgentPaaS ${args.agent_name}(等待确认)`,
-        output: `任务正在等待人工确认。run_id=${String(metadata.run_id ?? "")}。请检查待确认材料并提交决定;这不是任务失败。`,
-        metadata,
-      }
+    const result = formatRunResult(agentName, { ...done, ...metadata, output: done.output }, metadata)
     return {
-      title: `AgentPaaS ${args.agent_name}(失败)`,
+      ...result,
       output: [
-        `[AgentPaaS 执行失败] ${errors.join("; ") || String(done.output ?? "") || "未知错误"}`,
-        metadata.workspace_path
-          ? `已有产物保留在:${metadata.workspace_path}`
-          : "已有产物已保留;请根据 run_id 查询工作区。",
-        "失败严禁自动重跑、拆分任务或调用其他 subagent;必须先向用户报告真实错误和已有产物,等待用户明确指令。",
-      ].join("\n"),
-      metadata,
+        args.background ? "后台任务已受理;关闭当前观察不会取消执行。重新打开后用 /paas-tasks 找回。" : "",
+        preflight.summary,
+        result.output,
+        ...warnings,
+      ]
+        .filter(Boolean)
+        .join("\n"),
     }
   },
 })
 
-function scientificLabel(status: unknown): string {
-  if (status === "needs_evidence") return "证据不足"
-  if (status === "needs_revision") return "方案待修订"
-  if (status === "needs_review") return "方案检查通过,待人工审阅"
-  return "研究状态待核查"
-}
-
-async function resolveAgentID(config: { url: string; apiKey: string }, agentName: string, signal: AbortSignal) {
-  const name = agentName.trim()
-  if (!name) throw new Error("agent_name 不能为空")
-
-  let resp: Response
-  try {
-    resp = await fetch(`${config.url}/api/v1/agents`, {
-      headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json" },
-      signal,
-    })
-  } catch (err) {
-    throw new Error(`获取 AgentPaaS Agent 列表失败: ${err instanceof Error ? err.message : String(err)}`)
-  }
-  if (!resp.ok) {
-    const detail = await resp.text().catch(() => "")
-    throw new Error(`获取 AgentPaaS Agent 列表失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
-  }
-
-  const payload: unknown = await resp.json().catch(() => null)
-  if (!payload || typeof payload !== "object" || !("agents" in payload) || !Array.isArray(payload.agents)) {
-    throw new Error("AgentPaaS Agent 列表响应格式无效")
-  }
-  const agents = (payload.agents as unknown[]).flatMap((item): AgentListItem[] => {
-    if (!item || typeof item !== "object" || !("id" in item) || !("name" in item)) return []
-    if (typeof item.id !== "string" || typeof item.name !== "string") return []
-    return [{ id: item.id, name: item.name }]
-  })
-  const matches = agents.filter((item) => item.name === name)
-  if (matches.length === 0) throw new Error(`AgentPaaS 中没有名为“${name}”的 active Agent`)
-  if (matches.length > 1)
-    throw new Error(`AgentPaaS 中存在 ${matches.length} 个名为“${name}”的 active Agent,无法确定调用目标`)
-  if (!isSupportedAgentID(matches[0].id)) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
-  return matches[0].id
-}
-
-// Parse the two supported ID namespaces; never accept URL/path syntax or decoded input.
-// Every route still encodes the complete ID as one path segment.
-function isSupportedAgentID(id: string): boolean {
-  if (id.length > 512) return false
-  if (id.startsWith("ag_")) return /^ag_[A-Za-z0-9_-]+$/.test(id)
-  const parts = id.split(":")
-  if (parts.length !== 3 || parts[0] !== "locostack") return false
-  const namespace = parts[1]
-  const name = parts[2]
-  const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
-  return (
-    namespace.length <= 63 &&
-    label.test(namespace) &&
-    name.length <= 253 &&
-    name.split(".").every((part) => part.length <= 63 && label.test(part))
-  )
-}
-
-const conversationCache = new Map<string, ConversationState>()
-
-function digest(value: string) {
-  return new Bun.CryptoHasher("sha256").update(value).digest("hex")
-}
-
-function canonicalJSON(value: unknown): string {
-  if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`
-  const record = asRecord(value)
-  if (record)
-    return `{${Object.keys(record)
-      .sort()
-      .filter((key) => record[key] !== undefined)
-      .map((key) => `${JSON.stringify(key)}:${canonicalJSON(record[key])}`)
-      .join(",")}}`
-  return JSON.stringify(value) ?? "null"
-}
-
-async function requestContext(
-  config: { url: string; apiKey: string },
-  agentID: string,
-  key: string,
-  current: ConversationState & { intent: string },
-) {
-  const directory = path.join(agentPaaSConversationDir(), "requests")
-  const filePath = path.join(directory, `${digest(canonicalJSON([config.url, config.apiKey, agentID, key]))}.json`)
-  await mkdir(directory, { recursive: true, mode: 0o700 })
-  const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
-  await Bun.write(temporary, JSON.stringify(current), { mode: 0o600 })
-  // link is atomic and never replaces the first submission's saved context.
-  const created = await link(temporary, filePath)
-    .then(
-      () => true,
-      (error: NodeJS.ErrnoException) => {
-        if (error.code === "EEXIST") return false
-        throw error
-      },
-    )
-    .finally(() => rm(temporary, { force: true }))
-  const saved = asRecord(await Bun.file(filePath).json())
-  if (!saved || typeof saved.threadID !== "string" || typeof saved.runID !== "string")
-    throw new Error("AgentPaaS 提交记录损坏,停止提交;请先检查原运行状态。")
-  if (saved.intent !== current.intent) throw new Error("AgentPaaS 幂等键已用于不同请求;修改后的任务必须使用新键。")
-  return { threadID: saved.threadID, runID: saved.runID, created }
-}
-
-async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
-  const cacheKey = `${sessionID}\n${key}`
-  const cached = conversationCache.get(cacheKey)
-  if (cached) return cached
-
-  const file = Bun.file(conversationStatePath(sessionID))
-  if (!(await file.exists())) return undefined
-  try {
-    const parsed: unknown = await file.json()
-    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
-    const value = (parsed as Record<string, unknown>)[key]
-    if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
-    const threadID = getString((value as Record<string, unknown>).thread_id)
-    const runID = getString((value as Record<string, unknown>).run_id)
-    if (!threadID || !runID) return undefined
-    const state = { threadID, runID }
-    conversationCache.set(cacheKey, state)
-    return state
-  } catch {
-    return undefined
-  }
-}
-
-async function saveConversationState(sessionID: string, key: string, state: ConversationState) {
-  conversationCache.set(`${sessionID}\n${key}`, state)
-  const filePath = conversationStatePath(sessionID)
-  const file = Bun.file(filePath)
-  let saved: Record<string, unknown> = {}
-  if (await file.exists()) {
-    try {
-      const parsed: unknown = await file.json()
-      if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) saved = parsed as Record<string, unknown>
-    } catch {
-      saved = {}
-    }
-  }
-  saved[key] = { thread_id: state.threadID, run_id: state.runID }
-  try {
-    await mkdir(agentPaaSConversationDir(), { recursive: true, mode: 0o700 })
-    await Bun.write(filePath, `${JSON.stringify(saved, null, 2)}\n`)
-  } catch {
-    // The in-memory mapping still preserves continuity for this CLI process.
-  }
-}
-
-function conversationStatePath(sessionID: string) {
-  return path.join(agentPaaSConversationDir(), `${sessionID.replace(/[^A-Za-z0-9._-]/g, "_")}.json`)
-}
-
-async function loadAgentPaaSConfig() {
-  const configPath = agentPaaSConfigPath()
-  const file = Bun.file(configPath)
-  let saved: Record<string, unknown> = {}
-  if (await file.exists()) {
-    try {
-      const parsed: unknown = await file.json()
-      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("根节点必须是 JSON 对象")
-      saved = parsed as Record<string, unknown>
-    } catch (err) {
-      throw new Error(`AgentPaaS 配置文件无效:${configPath}\n${err instanceof Error ? err.message : String(err)}`)
-    }
-  }
-
-  const savedUrl = typeof saved.server === "string" ? saved.server.trim() : ""
-  const savedApiKey = typeof saved.api_key === "string" ? saved.api_key.trim() : ""
-  const url = (process.env.AGENTPAAS_URL?.trim() || savedUrl || DEFAULT_AGENTPAAS_URL).replace(/\/+$/, "")
-  const apiKey = process.env.AGENTPAAS_API_KEY?.trim() || savedApiKey
-  if (apiKey) return { url, apiKey }
-
-  throw new Error(`AgentPaaS 尚未配置。请创建 ${configPath}:
-{
-  "server": "${DEFAULT_AGENTPAAS_URL}",
-  "api_key": "你的 AgentPaaS API Key"
-}
-也可以通过 AGENTPAAS_URL 和 AGENTPAAS_API_KEY 环境变量临时覆盖。`)
-}
-
-function agentPaaSHome() {
-  return process.env.HOME?.trim() || homedir()
-}
-
-function agentPaaSConfigPath() {
-  return path.join(agentPaaSHome(), ".agentpaas", "config.json")
-}
-
-function agentPaaSConversationDir() {
-  return (
-    process.env.AGENTPAAS_BRIDGE_STATE_DIR?.trim() || path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
-  )
-}
-
 async function resolveConfirmation(
   config: { url: string; apiKey: string },
   agentID: string,
@@ -609,29 +534,15 @@ async function resolveConfirmation(
       Authorization: `Bearer ${config.apiKey}`,
       "Content-Type": "application/json",
     },
-    body: JSON.stringify({ approved, ...(remote && approvalID ? { approval_id: approvalID } : {}) }),
+    body: JSON.stringify({ approved, approval_id: approvalID }),
     signal,
+    redirect: "error",
   })
   if (resp.ok) return
   const detail = await resp.text().catch(() => "")
   throw new Error(`AgentPaaS 确认请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
 }
 
-async function cancelRemoteRun(config: { url: string; apiKey: string }, agentID: string, runID: string) {
-  const signal = AbortSignal.timeout(5000)
-  const resp = await fetch(
-    `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}/cancel`,
-    {
-      method: "POST",
-      headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json" },
-      signal,
-    },
-  )
-  if (resp.ok) return
-  const detail = await resp.text().catch(() => "")
-  throw new Error(`AgentPaaS 取消请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
-}
-
 async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
   const reader = body.getReader()
   const decoder = new TextDecoder()

+ 73 - 0
evals/agentpaas-acceptance/README.md

@@ -0,0 +1,73 @@
+# PaaS + OpenCode 单任务验收
+
+目的:用固定输入检查实际部署智能体是否能接收文件、执行任务、登记产物、恢复观察,并记录可核查结果。它是小规模验收,不是“优于原始 Qwen”的对比实验,也不代表覆盖 125 个科学问题。
+
+## 用例与指标
+
+| 用例 | 固定输入 | 自动检查 | 必须人工检查 |
+| --- | --- | --- | --- |
+| data | 有重复和缺失值的 6 行 CSV | 行数、去重数、缺失数、组均值、总体均值、实际清洗行与输入逐行比对、独立重算、产物存在 | 处理解释是否合理 |
+| qa | 有版本覆盖和未知信息的合成政策 | 当前值、来源版本、未知字段为 null、引文逐字对照来源、产物存在 | 引文是否支持答案,是否正确区分冲突与版本替换 |
+| software | 明确的函数行为和四个边界输入 | 输出案例值及源码/测试文件存在 | 审阅后在独立沙箱执行测试;脚本不执行生成代码 |
+| research | 含混杂因素、反例和测量规范的合成证据 | 证据 ID、逐字引文、禁止因果夸大/伪称模拟的字段、产物存在 | 假设可证伪性、反证处理、计划可执行性;不是科学真实性证明 |
+
+自动检查逐项记录通过/未通过和分子分母,不生成综合质量总分。它们不等权代表科研质量,也不保证 JSON 声明与报告一致。人工项初始为 `not_reviewed`;`quality_score` 保持 null。任务状态、是否取得产物、已报告 tokens/费用、从提交开始的观察墙钟时间单列。费用缺失为 null;服务端报告 0 不足以证明免费。初轮 `correction_rounds=0`;后续修订需单独保存 run_id、父版本、反馈与评分,不能覆盖首轮并当成首轮成功。
+
+## 运行
+
+需要本仓库开发依赖、运行中的 PaaS/执行服务、已部署的目标智能体,以及 `AGENTPAAS_URL` / `AGENTPAAS_API_KEY`(或已有本机配置)。不要把凭证写进本目录。智能体必须实际具有输入读取和产物写出能力;不要只按名称猜能力。
+
+从 OpenCode 仓库根目录:
+
+```bash
+# 无网络、无模型调用,查看完整用例与标准
+bun script/accept-agentpaas.ts
+
+# 只读实际目录,选定 agent_id
+bun script/accept-agentpaas.ts --catalog
+
+# 一次只运行一个用例;output 的父目录必须存在,目标目录必须不存在
+bun script/accept-agentpaas.ts --run --case data --agent-id ag_实际ID \
+  --model-label 部署中实际模型版本 --output /tmp/paas-data-01 --wait-seconds 120
+
+# 未结束、等待审批或观察断开后,只恢复原任务,不重复上传或提交
+bun script/accept-agentpaas.ts --resume --output /tmp/paas-data-01 --wait-seconds 120
+```
+
+替换 `--case` 为 qa/software/research,分别选择适合该能力的智能体和新的输出目录。默认不探测模型;`--probe-model` 会额外发起小额模型请求,消耗由服务端计量。`--model-label` 是操作者记录的版本,脚本不会谎称已自动验证它,应先与部署配置核对。
+
+脚本使用实际 OpenCode 桥接工具,先检查资源再上传固定输入,随后后台提交一次任务。`--wait-seconds` 为 0–900,只限制本轮观察时间,**不是模型费用或服务端运行时间上限**;停止服务端任务须在 OpenCode 用 `agentpaas_task cancel`。脚本不会自动批准工具操作;遇到审批,先在 OpenCode 展示材料并作决定,再 resume。第一次响应不明确且 receipt 处于 submitting、没有 run_id 时,`--resume` 按原请求键只读找回;仍查不到则保留凭据并提示核查,不重新运行 `--run`。
+
+输出包括:
+
+- `receipt.json`:用例、模型标签、请求键、文件 ID、run_id、恢复信息;仅限本机保存,勿提交。
+- `result.json`:执行状态、自动检查、未评审项、耗时和服务端已报告用量。
+- `result.md`:该次快照的结果与下一步提示。
+- `.agentpaas-artifacts/`:经带鉴权接口下载并校验的真实文件。
+
+未结束时 `automatic_checks=null`,不能计为质量失败或通过。`completed` 但关键文件缺失时,自动项必须失败。验收需同时报告未完成、审批等待、资源阻断等任务,不能只统计成功产出者。
+
+## 后续效果对比
+
+通过小样例后,再冻结模型版本、采样设置、输入证据、工具权限、预算和案例集,对原始 Qwen 与系统做配对重复实验。按场景分别报告完成率、产物检查、盲评质量、修订收益和成本;先做独立评分,再决定是否需要事先规定权重的总分。本目录不提供未经运行的模型成绩。
+
+
+## 独立产物检查
+
+运行模式由 cases.json 固定为 iterate,并写入 receipt/result,避免科研入口只返回聊天说明。data 自动检查实际下载的 cleaned.csv,并从原输入重新计算去重、缺失和均值;自报 JSON 正确但 CSV 错误仍不能全通过。qa/research 要求 evidence_quotes,逐字核对指定来源及报告中的引用;这仅验证出处,不能代替人工判断引文是否支持主张。软件的 result_cases 是模型自报值,不能计为代码测试通过。
+
+软件独立测试是 `independent-software-check.py` 中的 9 个固定案例,与模型生成的 test_solution.py 分开。先审阅 solution.py,然后在已有可信 Python 镜像的隔离容器运行(替换两个绝对路径;镜像必须已在本机,不自动拉取):
+
+```bash
+docker run --rm --pull=never --network=none --read-only \
+  --cap-drop=ALL --security-opt=no-new-privileges --pids-limit=32 \
+  --memory=128m --cpus=1 --user=65534:65534 \
+  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
+  --mount type=bind,src=/绝对路径/solution.py,dst=/work/solution.py,readonly \
+  --mount type=bind,src=/绝对路径/independent-software-check.py,dst=/work/check.py,readonly \
+  python:3.12-alpine python -B -I /work/check.py /work/solution.py
+```
+
+脚本限制 10 秒执行并输出逐例结果;保留 Docker 退出码和原始输出,异常/超时算执行失败。Docker 未运行、镜像缺失或没有实际执行时填写 not_run,不能填 0/9 或 9/9。本轮开发环境 Docker 未运行,尚未执行该容器验收。主验收不会在宿主机自动执行生成代码。
+
+统计自动检查通过数和人工质量结果时分别展示,不合并成未经校准的总分。usage_complete / cost_complete 缺失或 false 时,只报告已观测用量,费用估算不能当成供应商账单。

+ 122 - 0
evals/agentpaas-acceptance/cases.json

@@ -0,0 +1,122 @@
+[
+  {
+    "id": "data",
+    "capability": "数据分析",
+    "files": [
+      "measurements.csv"
+    ],
+    "prompt": "分析上传的 measurements.csv。按 id 去重,重复 id 保留第一条;value 空白行在去重之后剔除。不插补。输出 acceptance.json,字段 valid_rows、excluded_missing、duplicates_removed、group_means(A 和 B)、overall_mean。另输出 cleaned.csv 和解释处理顺序的 report.md。所有数值必须从输入计算。",
+    "artifact": "acceptance.json",
+    "expected": {
+      "valid_rows": 4,
+      "excluded_missing": 1,
+      "duplicates_removed": 1,
+      "group_means": {
+        "A": 3,
+        "B": 7
+      },
+      "overall_mean": 5
+    },
+    "required_artifacts": [
+      "acceptance.json",
+      "cleaned.csv",
+      "report.md"
+    ],
+    "review": [
+      "cleaned.csv 是否恰好包含 id 1、2、3、4,且未修改有效值",
+      "报告是否解释去重及缺失值处理,没有捏造推断"
+    ],
+    "mode": "iterate"
+  },
+  {
+    "id": "qa",
+    "capability": "文档问答",
+    "files": [
+      "policy.txt"
+    ],
+    "prompt": "只依据上传的 policy.txt 回答:当前月度上限是多少?旧规定是否冲突?文本能否确定外部协作者限额?输出 acceptance.json,字段 monthly_limit、effective_source、superseded_source、external_collaborator_limit(不知道用 null)、evidence_ids。另输出 report.md,逐条引用文档原句并解释新旧版本关系;禁止外部检索和猜测。 acceptance.json 还需 evidence_quotes 对象,键为 P1、P2、P3,值为对应来源的逐字原句(每句至少 12 字);report.md 引用同样原句。",
+    "artifact": "acceptance.json",
+    "expected": {
+      "monthly_limit": 12,
+      "effective_source": "P2",
+      "superseded_source": "P1",
+      "external_collaborator_limit": null
+    },
+    "required_artifacts": [
+      "acceptance.json",
+      "report.md"
+    ],
+    "review": [
+      "每条答案的引文是否确实支持主张",
+      "是否区分版本覆盖与尚未解决的冲突",
+      "未知问题是否明确拒绝猜测"
+    ],
+    "mode": "iterate"
+  },
+  {
+    "id": "software",
+    "capability": "软件工程",
+    "files": [
+      "software-task.md"
+    ],
+    "prompt": "完成上传的 software-task.md。生成 solution.py、test_solution.py、report.md 和 acceptance.json。acceptance.json 包含 result_cases,即题目给定四个输入的 normalize_labels 返回值。不要安装依赖、访问网络或执行破坏性命令;说明你实际进行了哪些验证,没有运行测试就明确说明。",
+    "artifact": "acceptance.json",
+    "expected": {
+      "result_cases": [
+        [
+          "alpha",
+          "beta"
+        ],
+        [],
+        [
+          "a",
+          "b"
+        ],
+        [
+          "x"
+        ]
+      ]
+    },
+    "required_artifacts": [
+      "acceptance.json",
+      "solution.py",
+      "test_solution.py",
+      "report.md"
+    ],
+    "review": [
+      "在独立沙箱审阅代码后运行测试,记录真实通过数;本脚本不会执行模型生成的代码",
+      "是否覆盖空白、大小写去重、非字符串值及顺序保持",
+      "代码是否满足函数签名且无额外副作用"
+    ],
+    "mode": "iterate"
+  },
+  {
+    "id": "research",
+    "capability": "科学假设与计划",
+    "files": [
+      "evidence.md"
+    ],
+    "prompt": "针对上传 evidence.md 中的合成问题,形成可证伪假设和后续研究计划。资料是人工构造的测试材料,不是真实科学发现。不得把 E1 的相关性当因果,不得忽略 E2 反例。输出 acceptance.json,字段 evidence_ids、causal_claim_supported(布尔值)、simulation_executed(布尔值);另输出 report.md,列明支持证据、反证、变量、对照、测量指标、失败判据,以及补充何种证据会改变结论。不接入真实实验,不得宣称进行了模拟。 acceptance.json 还需 evidence_quotes 对象,键为 E1、E2、E3,值为对应来源的逐字原句(每句至少 12 字);report.md 引用同样原句。",
+    "artifact": "acceptance.json",
+    "expected": {
+      "evidence_ids": [
+        "E1",
+        "E2",
+        "E3"
+      ],
+      "causal_claim_supported": false,
+      "simulation_executed": false
+    },
+    "required_artifacts": [
+      "acceptance.json",
+      "report.md"
+    ],
+    "review": [
+      "假设是否来自给定证据,且反证被实际纳入推理",
+      "是否包含可测量的结果与足以推翻假设的判据",
+      "研究计划是否区分观测与干预,且预算与执行条件明确",
+      "此用例只检查证据约束与流程,不证明真实科学创新或 125 问题覆盖"
+    ],
+    "mode": "iterate"
+  }
+]

+ 9 - 0
evals/agentpaas-acceptance/evidence.md

@@ -0,0 +1,9 @@
+# 合成证据包
+
+问题:因素 X 是否会提高过程 Y 的产率?不对应已确立的真实科学结论。
+
+- E1:观测性样本,n=40;X 较高组的平均产率比 X 较低组高 8%。两组温度分布不同,尚未控制温度。
+- E2:另一批样本,n=12;固定温度后的随机干预中,提高 X 未观察到平均产率改善。测量噪声较大,统计功效未知。
+- E3:测量规范建议每批使用盲法重复测量、预先登记排除标准,记录温度和设备批次。它不是 X 导致 Y 的实验证据。
+
+限制:当前未连接仪器,也未提供可执行仿真模型;只能产出研究计划。

+ 39 - 0
evals/agentpaas-acceptance/independent-software-check.py

@@ -0,0 +1,39 @@
+"""Reviewer-owned behavioral checks. Run ONLY inside the documented isolated container."""
+import importlib.util
+import json
+import signal
+import sys
+
+CASES = [
+    ([" Alpha ", "beta", "ALPHA", ""], ["alpha", "beta"]),
+    ([None, 3, " "], []),
+    (["A", "B", "a", " b "], ["a", "b"]),
+    (["x", False, " X "], ["x"]),
+    ([], []),
+    ([{}, [], True, 0, 1.2], []),
+    ([" B ", "a", "b", "A", "c"], ["b", "a", "c"]),
+    (["\tALPHA\n", "alpha", "\n"], ["alpha"]),
+    (["Ä", "ä", "中文", " 中文 "], ["ä", "中文"]),
+]
+
+
+def main():
+    signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError("time limit")))
+    signal.alarm(10)
+    spec = importlib.util.spec_from_file_location("candidate", sys.argv[1])
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    results = []
+    for index, (values, expected) in enumerate(CASES):
+        try:
+            actual = module.normalize_labels(values)
+            results.append({'case': index + 1, 'passed': isinstance(actual, list) and actual == expected})
+        except Exception as error:
+            results.append({'case': index + 1, 'passed': False, 'error': type(error).__name__})
+    passed = sum(item['passed'] for item in results)
+    print(json.dumps({'suite': 'reviewer-independent-v1', 'passed': passed, 'total': len(CASES), 'checks': results}))
+    return 0 if passed == len(CASES) else 1
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 7 - 0
evals/agentpaas-acceptance/measurements.csv

@@ -0,0 +1,7 @@
+id,group,value
+1,A,2
+2,A,4
+3,B,6
+4,B,8
+4,B,8
+5,A,

+ 4 - 0
evals/agentpaas-acceptance/policy.txt

@@ -0,0 +1,4 @@
+[P1] 版本 1,生效日期 2025-01-01:内部成员每月最多 8 次申请。
+[P2] 版本 2,生效日期 2025-06-01:替代版本 1 的月度限额条款,内部成员每月最多 12 次申请。
+[P3] 此文档不规定外部协作者的申请限额。
+测试时点为 2025-07-01。以上为合成政策,仅用于验收。

+ 11 - 0
evals/agentpaas-acceptance/software-task.md

@@ -0,0 +1,11 @@
+实现 Python 函数 `normalize_labels(values: list) -> list[str]`。
+忽略非字符串值;字符串 strip 后转为小写;忽略空字符串;去重且保持首次出现顺序。
+不使用第三方依赖。
+
+给定四个输入:
+1. `[" Alpha ", "beta", "ALPHA", ""]`
+2. `[null, 3, " "]`(Python 中 null 对应 None)
+3. `["A", "B", "a", " b "]`
+4. `["x", false, " X "]`(Python 中 false 对应 False)
+
+使用 unittest 编写包含上述边界与至少一个补充案例的测试。

+ 192 - 0
packages/opencode/test/tool/agentpaas-acceptance.test.ts

@@ -0,0 +1,192 @@
+import { expect, test } from "bun:test"
+import path from "node:path"
+import { evaluateAcceptance, type AcceptanceCase } from "../../../../script/lib/agentpaas-acceptance"
+import { tmpdir } from "../fixture/fixture"
+
+const root = path.resolve(import.meta.dir, "../../../..")
+const samples = (await Bun.file(path.join(root, "evals/agentpaas-acceptance/cases.json")).json()) as AcceptanceCase[]
+
+test("malformed JSON and missing outputs cannot pass even if the execution status says completed", () => {
+  const sample = samples.find((item) => item.id === "data")!
+  const missing = evaluateAcceptance(sample, [], "not-json")
+  expect(missing.passed).toBe(0)
+  expect(missing.total).toBe(11)
+  expect(missing.human_review.every((item) => item.result === "not_reviewed")).toBe(true)
+  const wrong = evaluateAcceptance(
+    sample,
+    sample.required_artifacts,
+    JSON.stringify({ valid_rows: 6, excluded_missing: 0, duplicates_removed: 0, group_means: {}, overall_mean: 0 }),
+  )
+  expect(wrong.passed).toBe(4)
+  const correct = evaluateAcceptance(
+    sample,
+    sample.required_artifacts,
+    '{"valid_rows":4,"excluded_missing":1,"duplicates_removed":1,"group_means":{"B":7,"A":3},"overall_mean":5}',
+    { "cleaned.csv": "id,group,value\n1,A,2\n2,A,4\n3,B,6\n4,B,8" },
+    { "measurements.csv": "id,group,value\n1,A,2\n2,A,4\n3,B,6\n4,B,8\n4,B,8\n5,A," },
+  )
+  expect(correct.passed).toBe(correct.total)
+})
+
+test("default CLI only lists fixed cases without requiring credentials or contacting a server", async () => {
+  const child = Bun.spawn([process.execPath, path.join(root, "script/accept-agentpaas.ts")], {
+    env: { ...process.env, AGENTPAAS_URL: "http://127.0.0.1:1", AGENTPAAS_API_KEY: "unused" },
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+  const output = await new Response(child.stdout).text()
+  const error = await new Response(child.stderr).text()
+  expect(await child.exited, error).toBe(0)
+  const result = JSON.parse(output)
+  expect(result.mode).toBe("plan_only")
+  expect(result.cases.map((sample: AcceptanceCase) => sample.id)).toEqual(["data", "qa", "software", "research"])
+})
+
+test("CLI resume observes one existing task and downloads verified outputs without resubmitting", async () => {
+  await using temp = await tmpdir()
+  const admissions: unknown[] = []
+  const uploads: string[] = []
+  let finished = false
+  const bytes = [
+    '{"valid_rows":4,"excluded_missing":1,"duplicates_removed":1,"group_means":{"A":3,"B":7},"overall_mean":5}',
+    "id,group,value\n1,A,2\n2,A,4\n3,B,6\n4,B,8\n",
+    "Deduplicated before removing missing values.\n",
+  ]
+  const artifacts = ["acceptance.json", "cleaned.csv", "report.md"].map((name, index) => ({
+    name,
+    artifact_id: `art_${index}`,
+    size_bytes: Buffer.byteLength(bytes[index]),
+    sha256: new Bun.CryptoHasher("sha256").update(bytes[index]).digest("hex"),
+  }))
+  const file = { file_id: "file_1", name: "measurements.csv", status: "available" }
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(request) {
+      expect(request.headers.get("Authorization")).toBe("Bearer acceptance-fixture")
+      const url = new URL(request.url)
+      if (url.pathname.endsWith("/agents")) return Response.json({ agents: [{ id: "ag_test", name: "Data67" }] })
+      if (url.pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
+      if (url.pathname.endsWith("/files/capabilities")) return Response.json({ direct_upload_max_bytes: 4096 })
+      if (url.pathname.endsWith("/files") && request.method === "POST") {
+        uploads.push(await request.text())
+        return Response.json(file)
+      }
+      if (url.pathname.endsWith("/files/file_1")) return Response.json(file)
+      if (url.pathname.endsWith("/run/stream")) {
+        admissions.push(await request.json())
+        return new Response('event: started\ndata: {"run_id":"run_accept","thread_id":"thread_accept"}\n\n')
+      }
+      if (url.pathname.includes("/requests/"))
+        return Response.json({ found: true, run: { run_id: "run_accept", status: finished ? "completed" : "running" } })
+      if (url.pathname.endsWith("/runs/run_accept"))
+        return Response.json({
+          run_id: "run_accept",
+          status: finished ? "completed" : "running",
+          artifacts: finished ? artifacts : [],
+          usage: { total_tokens: 42 },
+        })
+      if (url.pathname.endsWith("/artifacts")) return Response.json({ artifacts })
+      const index = /\/artifacts\/art_(\d)\/download$/.exec(url.pathname)?.[1]
+      if (index !== undefined) return new Response(bytes[Number(index)])
+      return Response.json({ error: "unexpected route" }, { status: 404 })
+    },
+  })
+  const output = path.join(temp.path, "result")
+  const call = async (args: string[]) => {
+    const child = Bun.spawn(
+      [
+        process.execPath,
+        path.join(root, "script/accept-agentpaas.ts"),
+        ...args,
+        "--output",
+        output,
+        "--wait-seconds",
+        "0",
+      ],
+      {
+        env: {
+          ...process.env,
+          AGENTPAAS_URL: server.url.origin,
+          AGENTPAAS_API_KEY: "acceptance-fixture",
+          AGENTPAAS_BRIDGE_STATE_DIR: path.join(temp.path, "state"),
+        },
+        stdout: "pipe",
+        stderr: "pipe",
+      },
+    )
+    const data = await new Response(child.stdout).text()
+    const error = await new Response(child.stderr).text()
+    expect(await child.exited, error).toBe(0)
+    return JSON.parse(data)
+  }
+  try {
+    const first = await call([
+      "--run",
+      "--case",
+      "data",
+      "--agent-id",
+      "ag_test",
+      "--model-label",
+      "synthetic-executor-no-model",
+    ])
+    expect(first.observation_complete).toBe(false)
+    expect(first.automatic_checks).toBeNull()
+    finished = true
+    const lostReceipt = await Bun.file(path.join(output, "receipt.json")).json()
+    delete lostReceipt.run_id
+    lostReceipt.phase = "submitting"
+    await Bun.write(path.join(output, "receipt.json"), JSON.stringify(lostReceipt))
+    const second = await call(["--resume"])
+    expect(second.run_id).toBe(first.run_id)
+    expect(second.automatic_checks.passed).toBe(11)
+    expect(second.quality_score).toBeNull()
+    expect(second.total_tokens).toBe(42)
+    expect(second.cost_usd_reported).toBeNull()
+    expect(admissions).toHaveLength(1)
+    expect(uploads).toHaveLength(1)
+    expect(uploads[0]).toContain("5,A,")
+    const receipt = await Bun.file(path.join(output, "receipt.json")).text()
+    expect(receipt).not.toContain("acceptance-fixture")
+    const again = await call(["--resume"])
+    expect(again.automatic_checks.passed).toBe(11)
+    expect(admissions).toHaveLength(1)
+  } finally {
+    await server.stop(true)
+  }
+})
+
+test("correct self-reported answers cannot conceal corrupted cleaned data", () => {
+  const sample = samples.find((item) => item.id === "data")!
+  const result = evaluateAcceptance(
+    sample,
+    sample.required_artifacts,
+    JSON.stringify(sample.expected),
+    { "cleaned.csv": "id,group,value\n1,A,999" },
+    { "measurements.csv": "id,group,value\n1,A,2\n2,A,4\n3,B,6\n4,B,8\n4,B,8\n5,A," },
+  )
+  expect(result.checks.find((row) => row.name === "independent:cleaned_rows")?.passed).toBe(false)
+})
+
+test("invented or swapped evidence quotes fail mechanical source checking", async () => {
+  const sample = samples.find((item) => item.id === "qa")!
+  const source = await Bun.file(path.join(root, "evals/agentpaas-acceptance/policy.txt")).text()
+  const quotes = Object.fromEntries(
+    source
+      .split("\n")
+      .filter((line) => /^\[P[123]\]/.test(line))
+      .map((line) => [line.slice(1, 3), line.slice(5)]),
+  )
+  const evaluate = (evidence_quotes: Record<string, string>) =>
+    evaluateAcceptance(
+      sample,
+      sample.required_artifacts,
+      JSON.stringify({ ...sample.expected, evidence_ids: ["P1", "P2", "P3"], evidence_quotes }),
+      { "report.md": Object.values(evidence_quotes).join("\n") },
+      { "policy.txt": source },
+    )
+  expect(evaluate(quotes).checks.every((row) => row.passed)).toBe(true)
+  expect(
+    evaluate({ ...quotes, P1: quotes.P2 }).checks.find((row) => row.name === "independent:source_quotes")?.passed,
+  ).toBe(false)
+})

+ 1 - 0
packages/opencode/test/tool/agentpaas-admission.test.ts

@@ -17,6 +17,7 @@ test("retries retain their original parent across session advancement and proces
     hostname: "127.0.0.1",
     port: 0,
     async fetch(request) {
+      if (new URL(request.url).pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
       if (new URL(request.url).pathname === "/api/v1/agents")
         return Response.json({ agents: [{ id: "ag_local", name: "local" }] })
       const body = (await request.json()) as Record<string, unknown>

+ 175 - 0
packages/opencode/test/tool/agentpaas-background.test.ts

@@ -0,0 +1,175 @@
+import { expect, test } from "bun:test"
+import path from "node:path"
+import { tmpdir } from "../fixture/fixture"
+import run from "../../../../.opencode/tool/agentpaas_run"
+import { check, tasks } from "../../../../.opencode/tool/agentpaas"
+
+async function fixture(report: Record<string, unknown>, statusAvailable = true) {
+  const temp = await tmpdir()
+  const saved = Object.fromEntries(
+    ["AGENTPAAS_URL", "AGENTPAAS_API_KEY", "AGENTPAAS_BRIDGE_STATE_DIR"].map((key) => [key, process.env[key]]),
+  )
+  const requests: Array<{ path: string; body: Record<string, unknown>; key: string | null }> = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(request) {
+      const url = new URL(request.url)
+      const body = request.method === "POST" ? await request.json() : {}
+      requests.push({ path: url.pathname, body, key: request.headers.get("Idempotency-Key") })
+      if (url.pathname === "/api/v1/agents") return Response.json({ agents: [{ id: "ag_data", name: "Data67" }] })
+      if (url.pathname.endsWith("/verify")) return Response.json(report)
+      if (url.pathname.endsWith("/run/stream"))
+        return new Response(
+          new ReadableStream({
+            start(controller) {
+              controller.enqueue(
+                new TextEncoder().encode('event: started\ndata: {"run_id":"run_long","thread_id":"thread_long"}\n\n'),
+              )
+              // The initial stream never ends; an exact retry replays a completed result.
+              if (requests.filter((item) => item.path.endsWith("/run/stream")).length > 1) {
+                controller.enqueue(
+                  new TextEncoder().encode(
+                    'event: done\ndata: {"run_id":"run_long","status":"completed","output":"done"}\n\n',
+                  ),
+                )
+                controller.close()
+              }
+            },
+          }),
+          { headers: { "Content-Type": "text/event-stream" } },
+        )
+      if (url.pathname.endsWith("/runs/run_long"))
+        return statusAvailable
+          ? Response.json({
+              run_id: "run_long",
+              session_id: "thread_long",
+              status: "running",
+              output: "long-private-report".repeat(1000),
+            })
+          : Response.json({ error: "unavailable" }, { status: 503 })
+      return Response.json({ error: "unexpected mutation" }, { status: 400 })
+    },
+  })
+  process.env.AGENTPAAS_URL = server.url.origin
+  process.env.AGENTPAAS_API_KEY = "background-fixture"
+  process.env.AGENTPAAS_BRIDGE_STATE_DIR = path.join(temp.path, "state")
+  const abort = new AbortController()
+  const context = {
+    sessionID: crypto.randomUUID(),
+    messageID: "one",
+    agent: "paas",
+    directory: temp.path,
+    worktree: temp.path,
+    abort: abort.signal,
+    metadata() {},
+    async ask() {
+      throw new Error("Must not auto approve")
+    },
+  }
+  return {
+    requests,
+    context,
+    abort,
+    path: temp.path,
+    async [Symbol.asyncDispose]() {
+      await server.stop(true)
+      for (const [key, value] of Object.entries(saved)) {
+        if (value === undefined) delete process.env[key]
+        else process.env[key] = value
+      }
+      await temp[Symbol.asyncDispose]()
+    },
+  }
+}
+const args = {
+  agent_id: "ag_data",
+  input: "Analyze selected data",
+  mode: "chat" as const,
+  conversation: "new" as const,
+  background: true,
+}
+
+test("required capability failure prevents admission, optional failure and unchecked model do not", async () => {
+  await using env = await fixture({
+    status: "blocked",
+    checks: [{ kind: "mcp", name: "search", required: true, status: "failed" }],
+  })
+  await expect(run.execute(args, env.context)).rejects.toThrow("任务未提交")
+  expect(env.requests.some((request) => request.path.endsWith("/run/stream"))).toBe(false)
+  expect(env.requests.find((request) => request.path.endsWith("/verify"))?.body).toEqual({
+    check_model: false,
+    knowledge_query: "",
+  })
+})
+
+test("background returns while stream is open, persists across process restart, and never cancels", async () => {
+  await using env = await fixture({
+    status: "unverified",
+    checks: [
+      { kind: "model", name: "qwen", required: true, status: "not_checked" },
+      { kind: "mcp", name: "optional", required: false, status: "failed" },
+    ],
+  })
+  const result = await run.execute(args, env.context)
+  if (typeof result === "string" || !result.metadata) throw new Error("structured result required")
+  expect(result.metadata.background).toBe(true)
+  expect(result.metadata.status).toBe("running")
+  expect(result.output).toContain("未验证")
+  expect(result.output).toContain("run_long")
+  expect(env.requests.find((request) => request.path.endsWith("/run/stream"))?.body.cancel_on_disconnect).toBe(false)
+  env.abort.abort()
+  const module = path.resolve(import.meta.dir, "../../../../.opencode/tool/agentpaas.ts")
+  const source = `import {tasks} from ${JSON.stringify(module)}; const result=await tasks.execute({}, { ...${JSON.stringify(env.context)}, sessionID:"reopened", abort:new AbortController().signal, metadata(){}, async ask(){throw Error("no")} }); console.log(JSON.stringify(result));`
+  const child = Bun.spawn([process.execPath, "--eval", source], { env: process.env, stdout: "pipe", stderr: "pipe" })
+  const output = await new Response(child.stdout).text()
+  const error = await new Response(child.stderr).text()
+  expect(await child.exited, error).toBe(0)
+  expect(JSON.parse(output).metadata.runs[0].run_id).toBe("run_long")
+  expect(JSON.parse(output).metadata.runs[0].status).toBe("running")
+  expect(output).not.toContain("long-private-report")
+  expect(env.requests.filter((request) => request.path.endsWith("/run/stream"))).toHaveLength(1)
+  expect(env.requests.some((request) => request.path.endsWith("/cancel"))).toBe(false)
+  process.env.AGENTPAAS_API_KEY = "another-account"
+  const other = await tasks.execute({}, { ...env.context, abort: new AbortController().signal })
+  if (typeof other === "string") throw new Error("structured result required")
+  expect(other.metadata?.runs).toEqual([])
+})
+
+test("unavailable snapshot preserves accepted run as unknown, not completed or failed", async () => {
+  await using env = await fixture({ status: "verified", checks: [] }, false)
+  const result = await run.execute(args, env.context)
+  if (typeof result === "string") throw new Error("structured result required")
+  expect(result.metadata?.status).toBe("unknown")
+  expect(result.metadata?.run_id).toBe("run_long")
+  const list = await tasks.execute({}, env.context)
+  if (typeof list === "string") throw new Error("structured result required")
+  expect(list.output).toContain("run_long")
+  expect(list.output).toContain("状态读取失败")
+  expect(env.requests.filter((request) => request.path.endsWith("/run/stream"))).toHaveLength(1)
+})
+
+test("changing observation mode preserves the original idempotency identity", async () => {
+  await using env = await fixture({ status: "verified", checks: [] })
+  await run.execute(args, env.context)
+  const replay = await run.execute({ ...args, background: false }, env.context)
+  if (typeof replay === "string") throw new Error("structured result required")
+  expect(replay.metadata?.run_id).toBe("run_long")
+  const admissions = env.requests.filter((request) => request.path.endsWith("/run/stream"))
+  expect(admissions).toHaveLength(2)
+  expect(admissions[0].key).toBe(admissions[1].key)
+  expect(admissions[0].body).toEqual(admissions[1].body)
+})
+
+test("explicit model and knowledge probe forwards bounded options without starting a task", async () => {
+  await using env = await fixture({ status: "verified", checks: [] })
+  await check.execute({ agent_id: "ag_data", check_model: true, knowledge_query: "evidence" }, env.context)
+  expect(env.requests.at(-1)?.body).toEqual({ check_model: true, knowledge_query: "evidence" })
+  expect(env.requests.some((request) => request.path.endsWith("/run/stream"))).toBe(false)
+})
+
+test("malformed verification fails closed before task admission", async () => {
+  await using env = await fixture({ status: "verified", checks: [{ required: "yes", status: "passed" }] })
+  await expect(run.execute(args, env.context)).rejects.toThrow("条目无效")
+  expect(env.requests.some((request) => request.path.endsWith("/run/stream"))).toBe(false)
+})

+ 575 - 0
packages/opencode/test/tool/agentpaas-client.test.ts

@@ -0,0 +1,575 @@
+import { expect, test } from "bun:test"
+import path from "node:path"
+import { pathToFileURL } from "node:url"
+import { tmpdir } from "../fixture/fixture"
+import run from "../../../../.opencode/tool/agentpaas_run"
+import { agents, task, upload, download } from "../../../../.opencode/tool/agentpaas"
+
+async function fixture(handler: (request: Request) => Response | Promise<Response>) {
+  const temp = await tmpdir()
+  const saved = Object.fromEntries(
+    ["AGENTPAAS_URL", "AGENTPAAS_API_KEY", "AGENTPAAS_BRIDGE_STATE_DIR"].map((key) => [key, process.env[key]]),
+  )
+  const requests: Array<{ method: string; path: string }> = []
+  const approvals: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(request) {
+      expect(request.headers.get("Authorization")).toBe("Bearer client-fixture")
+      requests.push({ method: request.method, path: new URL(request.url).pathname })
+      if (new URL(request.url).pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
+      return handler(request)
+    },
+  })
+  process.env.AGENTPAAS_URL = server.url.origin
+  process.env.AGENTPAAS_API_KEY = "client-fixture"
+  process.env.AGENTPAAS_BRIDGE_STATE_DIR = path.join(temp.path, "state")
+  return {
+    path: temp.path,
+    requests,
+    approvals,
+    context: {
+      sessionID: crypto.randomUUID(),
+      messageID: "m1",
+      agent: "paas",
+      directory: temp.path,
+      worktree: temp.path,
+      abort: new AbortController().signal,
+      metadata() {},
+      async ask(request: { permission: string }) {
+        approvals.push(request.permission)
+      },
+    },
+    async [Symbol.asyncDispose]() {
+      await server.stop(true)
+      for (const [key, value] of Object.entries(saved)) {
+        if (value === undefined) delete process.env[key]
+        else process.env[key] = value
+      }
+      await temp[Symbol.asyncDispose]()
+    },
+  }
+}
+
+const catalog = () =>
+  Response.json({ agents: [{ id: "ag_data", name: "Data67", runtime_mode: "local" }], discovery: { status: "ok" } })
+const stream = (status: string, extra: Record<string, unknown> = {}) =>
+  new Response(`event: done\ndata: ${JSON.stringify({ run_id: "run_one", status, output: "result", ...extra })}\n\n`, {
+    headers: { "Content-Type": "text/event-stream" },
+  })
+
+test("cold discovery retries reads and resolves a stable ID before submitting once", async () => {
+  let discovery = 0
+  await using env = await fixture((request) => {
+    if (new URL(request.url).pathname === "/api/v1/agents") {
+      if (++discovery === 1) return Response.json({ agents: [], discovery: { status: "refreshing" } })
+      return catalog()
+    }
+    return stream("completed")
+  })
+  const result = await run.execute(
+    { input: "data task", agent_id: "ag_data", mode: "iterate", conversation: "new" },
+    env.context,
+  )
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+  expect(result.metadata.status).toBe("completed")
+  expect(discovery).toBe(2)
+  expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
+  expect(result.metadata.research67_progress).toBeUndefined()
+})
+
+test("catalog preserves unavailable discovery and all agent types without executing", async () => {
+  await using env = await fixture(() =>
+    Response.json({ agents: [{ id: "ag_data", name: "Data67" }], discovery: { status: "unavailable" } }),
+  )
+  const result = await agents.execute({}, env.context)
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+  expect(result.output).toContain("Data67")
+  expect(result.output).toContain("unavailable")
+  expect(env.requests.every((item) => item.method === "GET")).toBe(true)
+})
+
+for (const [status, label] of [
+  ["partial", "部分完成"],
+  ["needs_clarification", "需要补充信息"],
+]) {
+  test(`preserves ${status} and artifacts without reporting execution failure`, async () => {
+    await using env = await fixture((request) =>
+      new URL(request.url).pathname === "/api/v1/agents"
+        ? catalog()
+        : stream(status, {
+            artifacts: [{ artifact_id: "art_1", name: "report.md" }],
+          }),
+    )
+    const result = await run.execute(
+      { agent_name: "Data67", input: "analyze", mode: "iterate", conversation: "new" },
+      env.context,
+    )
+    if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+    expect(result.title).toContain(label)
+    expect(result.output).toContain("art_1")
+    expect(result.output).not.toContain("执行失败")
+  })
+}
+
+test("dropped SSE reads original run state without resubmitting or cancelling", async () => {
+  await using env = await fixture(async (request) => {
+    if (new URL(request.url).pathname === "/api/v1/agents") return catalog()
+    if (request.method === "POST") {
+      expect((await request.json()).cancel_on_disconnect).toBe(false)
+      return new Response('event: started\ndata: {"run_id":"run_one","thread_id":"thread_1"}\n\n')
+    }
+    return Response.json({ run_id: "run_one", status: "running" })
+  })
+  const result = await run.execute(
+    { agent_name: "Data67", input: "analyze", mode: "iterate", conversation: "new" },
+    env.context,
+  )
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+  expect(result.metadata.status).toBe("running")
+  expect(result.metadata.connection_recovered).toBe(true)
+  expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
+  expect(result.output).toContain("watch")
+})
+
+test("watch replays durable events and never submits or cancels work", async () => {
+  let reads = 0
+  await using env = await fixture((request) => {
+    const url = new URL(request.url)
+    if (url.pathname.endsWith("/events")) {
+      expect(url.searchParams.get("source")).toBe("service")
+      return Response.json({ events: [{ sequence: 4, event_type: "stage_completed", data: { stage_id: "analysis" } }] })
+    }
+    return Response.json({ run_id: "run_one", status: ++reads >= 2 ? "completed" : "running", output: "done" })
+  })
+  const result = await task.execute(
+    { agent_id: "ag_data", run_id: "run_one", action: "watch", wait_seconds: 1 },
+    env.context,
+  )
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+  expect(result.metadata.status).toBe("completed")
+  expect(result.metadata.event_cursor).toBe(4)
+  expect(env.requests.every((item) => item.method === "GET")).toBe(true)
+})
+
+test("resume uses the existing checkpoint and one control mutation", async () => {
+  let resumed = false
+  await using env = await fixture(async (request) => {
+    if (request.method === "POST") {
+      expect(new URL(request.url).pathname).toEndWith("/run_one/resume")
+      expect(await request.json()).toEqual({ checkpoint_id: "cp_original" })
+      resumed = true
+    }
+    if (new URL(request.url).pathname.endsWith("/events")) return Response.json({ events: [] })
+    return Response.json({ run_id: "run_one", status: resumed ? "running" : "paused", checkpoint_id: "cp_original" })
+  })
+  const result = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "resume" }, env.context)
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+  expect(result.metadata.status).toBe("running")
+  expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
+})
+
+test("approval requires a user decision and binds the exact approval ID", async () => {
+  let approved = false
+  await using env = await fixture(async (request) => {
+    if (request.method === "POST") {
+      const body = await request.json()
+      expect(body.approval_id).toBe("approval_1")
+      expect(body.approved).toBe(true)
+      expect(body.idempotency_key).toStartWith("opencode-control-")
+      approved = true
+    }
+    if (new URL(request.url).pathname.endsWith("/events")) return Response.json({ events: [] })
+    return Response.json({
+      run_id: "run_one",
+      status: approved ? "running" : "waiting_human",
+      pending_approval: { approval_id: "approval_1", prompt: "review plan" },
+    })
+  })
+  await expect(
+    task.execute(
+      { agent_id: "ag_data", run_id: "run_one", action: "approve" },
+      {
+        ...env.context,
+        ask: async () => {
+          throw new Error("user denied")
+        },
+      },
+    ),
+  ).rejects.toThrow("user denied")
+  expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(0)
+  await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "approve" }, env.context)
+  expect(env.approvals).toContain("agentpaas_confirm")
+  expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
+})
+
+test("upload, submit references, and authenticated download preserve file bytes", async () => {
+  const content = "col1,col2\n1,2\n"
+  const hash = new Bun.CryptoHasher("sha256").update(content).digest("hex")
+  const ref = { file_id: "file_1", name: "input.csv", status: "available", sha256: hash, size_bytes: content.length }
+  await using env = await fixture(async (request) => {
+    const url = new URL(request.url)
+    if (url.pathname === "/api/v1/agents") return catalog()
+    if (url.pathname === "/api/v1/files") {
+      expect(await request.text()).toBe(content)
+      expect(request.headers.get("X-File-SHA256")).toBe(hash)
+      return Response.json(ref)
+    }
+    if (url.pathname === "/api/v1/files/capabilities")
+      return Response.json({ direct_upload_max_bytes: 256 * 1024 * 1024 })
+    if (url.pathname === "/api/v1/files/file_1") return Response.json(ref)
+    if (url.pathname.endsWith("/run/stream")) {
+      expect((await request.json()).input_files).toEqual([ref])
+      return stream("completed")
+    }
+    if (url.pathname.endsWith("/artifacts"))
+      return Response.json({
+        artifacts: [{ artifact_id: "art_1", name: "../../output.csv", sha256: hash, size_bytes: content.length }],
+      })
+    return new Response(content)
+  })
+  await Bun.write(path.join(env.path, "input.csv"), content)
+  const uploaded = await upload.execute({ paths: ["input.csv"] }, env.context)
+  if (typeof uploaded === "string" || !uploaded.metadata) throw new Error("Expected structured tool output")
+  expect(uploaded.metadata.files[0].file_id).toBe("file_1")
+  await run.execute(
+    { input: "analyze", agent_name: "Data67", mode: "iterate", conversation: "new", input_file_ids: ["file_1"] },
+    env.context,
+  )
+  const result = await download.execute({ agent_id: "ag_data", run_id: "run_one", artifact_id: "art_1" }, env.context)
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
+  expect(await Bun.file(result.metadata.local_path).text()).toBe(content)
+  expect(result.metadata.local_path).toStartWith(path.join(env.path, ".agentpaas-artifacts") + path.sep)
+  expect(env.approvals).toEqual(["agentpaas_upload", "agentpaas_download"])
+  await expect(
+    download.execute({ agent_id: "ag_data", run_id: "run_one", artifact_id: "art_1" }, env.context),
+  ).rejects.toThrow()
+  expect(await Bun.file(result.metadata.local_path).text()).toBe(content)
+})
+
+test("download rejects corrupt bytes before writing", async () => {
+  await using env = await fixture((request) =>
+    new URL(request.url).pathname.endsWith("/artifacts")
+      ? Response.json({ artifacts: [{ artifact_id: "art_1", name: "report", sha256: "0".repeat(64) }] })
+      : new Response("corrupted"),
+  )
+  await expect(
+    download.execute(
+      { agent_id: "ag_data", run_id: "run_one", artifact_id: "art_1", output_path: "result.txt" },
+      env.context,
+    ),
+  ).rejects.toThrow("摘要不匹配")
+  expect(await Bun.file(path.join(env.path, "result.txt")).exists()).toBe(false)
+})
+
+test("failed control mutation is never retried", async () => {
+  await using env = await fixture((request) =>
+    request.method === "POST"
+      ? Response.json({ error: "unavailable" }, { status: 503 })
+      : Response.json({ run_id: "run_one", status: "running" }),
+  )
+  const result = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "cancel" }, env.context)
+  if (typeof result === "string") throw new Error("Expected structured result")
+  expect(result.metadata?.cancellation_status).toBe("unconfirmed")
+  expect(result.metadata?.status).toBe("running")
+  const reread = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "status" }, env.context)
+  if (typeof reread === "string") throw new Error("Expected structured result")
+  expect(reread.metadata?.cancellation_status).toBe("unconfirmed")
+  expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
+})
+
+test("tool confirmation remains actionable after reconnect even while worker status is running", async () => {
+  let decided = false
+  await using env = await fixture(async (request) => {
+    if (request.method === "POST") {
+      expect((await request.json()).approved).toBe(false)
+      decided = true
+    }
+    if (new URL(request.url).pathname.endsWith("/events")) return Response.json({ events: [] })
+    return Response.json({
+      run_id: "run_one",
+      status: "running",
+      pending_confirmation: decided
+        ? null
+        : { approval_id: "tc_first", status: "pending", tool: "Shell", input_text: "command" },
+    })
+  })
+  const pending = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "status" }, env.context)
+  if (typeof pending === "string" || !pending.metadata) throw new Error("Expected structured result")
+  expect(pending.metadata.status).toBe("waiting_confirmation")
+  await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "reject" }, env.context)
+  expect(decided).toBe(true)
+})
+
+test("status survives an unavailable event journal; watch returns the known result with a warning", async () => {
+  await using env = await fixture((request) =>
+    new URL(request.url).pathname.endsWith("/events")
+      ? new Response("journal offline", { status: 503 })
+      : Response.json({
+          run_id: "run_old",
+          session_id: "thread_old",
+          status: "completed",
+          output: "verified answer",
+          artifacts: [{ artifact_id: "artifact_old", name: "result.csv" }],
+        }),
+  )
+  const status = await task.execute({ agent_id: "ag_data", run_id: "run_old", action: "status" }, env.context)
+  if (typeof status === "string") throw new Error("Expected structured output")
+  expect(status.output).toContain("verified answer")
+  expect(env.requests.some((request) => request.path.endsWith("/events"))).toBe(false)
+  const watched = await task.execute(
+    { agent_id: "ag_data", run_id: "run_old", action: "watch", wait_seconds: 0 },
+    env.context,
+  )
+  if (typeof watched === "string" || !watched.metadata) throw new Error("Expected structured output")
+  expect(watched.metadata.status).toBe("completed")
+  expect(watched.output).toContain("artifact_old")
+  expect(watched.output).toContain("事件读取暂不可用")
+})
+
+test("explicit historical selection persists the thread and parent for the next revision", async () => {
+  let submission: Record<string, unknown> = {}
+  await using env = await fixture(async (request) => {
+    const route = new URL(request.url).pathname
+    if (route === "/api/v1/agents") return catalog()
+    if (request.method === "POST") {
+      submission = await request.json()
+      return stream("completed")
+    }
+    return Response.json({ run_id: "run_old", session_id: "thread_old", status: "completed" })
+  })
+  const state = path.join(env.path, "state", `${env.context.sessionID}.json`)
+  await task.execute({ agent_id: "ag_data", run_id: "run_old", action: "status" }, env.context)
+  expect(await Bun.file(state).exists()).toBe(false)
+  await task.execute({ agent_id: "ag_data", run_id: "run_old", action: "select" }, env.context)
+  expect(Object.values(await Bun.file(state).json())).toContainEqual({ thread_id: "thread_old", run_id: "run_old" })
+  await run.execute(
+    { agent_id: "ag_data", input: "Revise the selected research", mode: "iterate", conversation: "continue" },
+    env.context,
+  )
+  expect(submission.thread_id).toBe("thread_old")
+  expect(submission.context).toMatchObject({ run_id: "run_old" })
+  expect(env.requests.filter((request) => request.method === "POST" && !request.path.endsWith("/verify"))).toHaveLength(
+    1,
+  )
+})
+
+test("replaying resolved tool confirmations never prompts or posts another decision", async () => {
+  await using env = await fixture((request) => {
+    const route = new URL(request.url).pathname
+    if (route === "/api/v1/agents") return catalog()
+    if (route.endsWith("/run/stream"))
+      return new Response(
+        [
+          'event: started\ndata: {"run_id":"run_old","thread_id":"thread_old"}\n\n',
+          'event: confirm_required\ndata: {"run_id":"run_old","approval_id":"tc_old","tool":"Shell"}\n\n',
+          'event: done\ndata: {"run_id":"run_old","status":"completed","output":"done"}\n\n',
+        ].join(""),
+      )
+    return Response.json({ run_id: "run_old", status: "completed", pending_confirmation: null })
+  })
+  const result = await run.execute(
+    { agent_id: "ag_data", input: "existing task", mode: "iterate", conversation: "continue" },
+    env.context,
+  )
+  if (typeof result === "string" || !result.metadata) throw new Error("Expected structured output")
+  expect(result.metadata.status).toBe("completed")
+  expect(env.approvals).toEqual([])
+  expect(env.requests.filter((request) => request.path.endsWith("/confirm"))).toEqual([])
+})
+
+test("ordinary confirmation rejects stale IDs before any mutation", async () => {
+  await using env = await fixture(() =>
+    Response.json({
+      status: "running",
+      pending_confirmation: { approval_id: "tc_new", status: "pending", tool: "Shell" },
+    }),
+  )
+  await expect(
+    task.execute({ agent_id: "ag_data", run_id: "run_one", action: "approve", approval_id: "tc_old" }, env.context),
+  ).rejects.toThrow("已过期")
+  expect(env.requests.every((request) => request.method === "GET")).toBe(true)
+  expect(env.approvals).toEqual([])
+})
+
+test("upload uses the server limit and refuses oversized inputs before upload", async () => {
+  await using env = await fixture(() => Response.json({ direct_upload_max_bytes: 3 }))
+  await Bun.write(path.join(env.path, "large.txt"), "four")
+  await expect(upload.execute({ paths: ["large.txt"] }, env.context)).rejects.toThrow("3 字节")
+  expect(env.requests.every((request) => request.method === "GET")).toBe(true)
+  expect(env.approvals).toEqual([])
+})
+
+test("installer works outside source tree and preserves existing user configuration", async () => {
+  await using directory = await tmpdir()
+  const config = path.join(directory.path, ".opencode/opencode.jsonc")
+  await Bun.write(config, '{"model":"user/model"}')
+  const script = path.resolve(import.meta.dir, "../../../../script/install-agentpaas.ts")
+  for (const extra of [[], ["--check"]]) {
+    const child = Bun.spawn([process.execPath, script, "--target", directory.path, ...extra], {
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
+  }
+  expect(await Bun.file(config).text()).toBe('{"model":"user/model"}')
+  expect(await Bun.file(path.join(directory.path, ".opencode/lib/agentpaas.ts")).exists()).toBe(true)
+  const installed = await import(pathToFileURL(path.join(directory.path, ".opencode/agentpaas-manifest.json")).href)
+  expect(installed.default.files).toContain("tool/agentpaas.ts")
+})
+
+test("lost started response is recovered by key without a second submission", async () => {
+  await using env = await fixture((request) => {
+    const url = new URL(request.url)
+    if (url.pathname === "/api/v1/agents") return catalog()
+    if (url.pathname.includes("/requests/"))
+      return Response.json({ found: true, run: { run_id: "run_one", session_id: "thread_one", status: "running" } })
+    return new Response("", { headers: { "Content-Type": "text/event-stream" } })
+  })
+  const result = await run.execute(
+    { input: "task", agent_id: "ag_data", mode: "iterate", conversation: "new", background: true },
+    env.context,
+  )
+  if (typeof result === "string") throw new Error("Expected structured result")
+  expect(result.metadata?.run_id).toBe("run_one")
+  expect(result.metadata?.admission_recovered).toBe(true)
+  expect(env.requests.filter((row) => row.method === "POST" && !row.path.endsWith("/verify"))).toHaveLength(1)
+})
+
+test("pending admission survives reload and only queries the original key", async () => {
+  const { pendingRequest, recoverPendingRequests } = await import("../../../../.opencode/lib/recovery")
+  let found = false
+  await using env = await fixture(() =>
+    Response.json(
+      found ? { found: true, run: { run_id: "r_recovered", status: "running" } } : { found: false, retry_safe: false },
+    ),
+  )
+  const config = { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" }
+  await pendingRequest(config, "ag_data", "receipt_one")
+  expect(await recoverPendingRequests(config, env.context.abort)).toHaveLength(1)
+  found = true
+  expect(await recoverPendingRequests(config, env.context.abort)).toHaveLength(0)
+  const count = env.requests.length
+  await recoverPendingRequests(config, env.context.abort)
+  expect(env.requests).toHaveLength(count)
+  expect(env.requests.every((row) => row.method === "GET")).toBe(true)
+})
+
+test("lost cancellation response is confirmed through authoritative snapshot", async () => {
+  await using env = await fixture((request) =>
+    request.method === "POST"
+      ? Response.json({ error: "response lost" }, { status: 503 })
+      : Response.json({ run_id: "run_one", status: "cancelled" }),
+  )
+  const result = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "cancel" }, env.context)
+  if (typeof result === "string") throw new Error("Expected structured result")
+  expect(result.metadata?.cancellation_status).toBe("confirmed")
+  expect(env.requests.filter((row) => row.method === "POST")).toHaveLength(1)
+})
+
+test("watch persists its event cursor and explicit after zero replays", async () => {
+  const cursors: string[] = []
+  await using env = await fixture((request) => {
+    const url = new URL(request.url)
+    if (url.pathname.endsWith("/events")) {
+      cursors.push(url.searchParams.get("after")!)
+      return Response.json({ events: [{ sequence: 7, event: "stage_completed", data: { stage_id: "evidence" } }] })
+    }
+    return Response.json({ run_id: "run_one", status: "completed" })
+  })
+  for (const after of [undefined, undefined, 0])
+    await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "watch", after }, env.context)
+  expect(cursors).toEqual(["0", "7", "0"])
+})
+
+test("approval reminders deduplicate decisions, warn before expiry, and never mutate", async () => {
+  const { approvalMonitor } = await import("../../../../.opencode/lib/approval-monitor")
+  const { trackRun } = await import("../../../../.opencode/lib/agentpaas")
+  let id = "approval_one",
+    expires = new Date(Date.now() + 120000).toISOString()
+  await using env = await fixture(() =>
+    Response.json({
+      status: "running",
+      pending_confirmation: {
+        status: "pending",
+        approval_id: id,
+        expires_at: expires,
+      },
+    }),
+  )
+  const config = { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" }
+  await trackRun(config, { agent_id: "ag_data", agent_name: "Data67", run_id: "run_one", thread_id: "thread_one" })
+  const messages: string[] = []
+  const monitor = approvalMonitor(config, async (message) => {
+    messages.push(message)
+  })
+  try {
+    await monitor.poll()
+    await monitor.poll()
+    expect(messages).toHaveLength(1)
+    expires = new Date(Date.now() + 30000).toISOString()
+    await monitor.poll()
+    await monitor.poll()
+    expect(messages).toHaveLength(2)
+    expect(messages[1]).toContain("即将到期")
+    id = "approval_two"
+    await monitor.poll()
+    expect(messages).toHaveLength(3)
+    monitor.dispose()
+    await monitor.poll()
+    expect(messages).toHaveLength(3)
+    expect(env.requests.every((row) => row.method === "GET")).toBe(true)
+  } finally {
+    monitor.dispose()
+  }
+})
+
+test("unsupported client cost budget blocks before submitting", async () => {
+  await using env = await fixture(() => catalog())
+  await expect(
+    run.execute(
+      { input: "task", agent_id: "ag_data", mode: "iterate", conversation: "new", max_cost_usd: 0.1 },
+      env.context,
+    ),
+  ).rejects.toThrow("未声明支持客户端费用预算")
+  expect(env.requests.filter((row) => row.method === "POST" && !row.path.endsWith("/verify"))).toHaveLength(0)
+})
+
+test("preflight cache is short-lived and version-scoped; explicit probes bypass it", async () => {
+  const { resolveAgentID, verifyAgent } = await import("../../../../.opencode/lib/agentpaas")
+  let version = 1
+  await using env = await fixture(() =>
+    Response.json({ agents: [{ id: "ag_data", name: "Data67", current_version: version }] }),
+  )
+  const config = { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" }
+  await resolveAgentID(config, "ag_data", env.context.abort)
+  await verifyAgent(config, "ag_data", env.context.abort, undefined, true)
+  expect((await verifyAgent(config, "ag_data", env.context.abort, undefined, true)).cache_hit).toBe(true)
+  await verifyAgent(config, "ag_data", env.context.abort)
+  version = 2
+  await resolveAgentID(config, "ag_data", env.context.abort)
+  expect((await verifyAgent(config, "ag_data", env.context.abort, undefined, true)).cache_hit).toBeUndefined()
+  expect(env.requests.filter((row) => row.path.endsWith("/verify"))).toHaveLength(3)
+})
+
+test("approval notification plugin exports a compatible lifecycle", async () => {
+  const plugin = await import("../../../../.opencode/plugin/agentpaas-notify")
+  expect(typeof plugin.default).toBe("function")
+})
+
+test("local journal failure cannot prevent cancellation reaching the server", async () => {
+  const { cancelAndObserve } = await import("../../../../.opencode/lib/recovery")
+  await using env = await fixture(() => Response.json({ status: "cancelled" }))
+  const blocked = path.join(env.path, "not-a-directory")
+  await Bun.write(blocked, "file")
+  process.env.AGENTPAAS_BRIDGE_STATE_DIR = blocked
+  const result = await cancelAndObserve(
+    { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" },
+    "ag_data",
+    "run_one",
+  )
+  expect(result.cancellation_status).toBe("confirmed")
+  expect(result.persistence_warning).toContain("未保存")
+  expect(env.requests.filter((row) => row.method === "POST")).toHaveLength(1)
+})

+ 53 - 10
packages/opencode/test/tool/agentpaas-protocol.test.ts

@@ -22,8 +22,18 @@ async function execute(id: string, events: Event[], approved = true, confirmatio
       const url = new URL(request.url)
       const raw = request.method === "POST" ? await request.text() : ""
       const body = raw ? JSON.parse(raw) : undefined
+      if (url.pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
       requests.push({ path: url.pathname, body })
       if (url.pathname === "/api/v1/agents") return Response.json({ agents: [{ id, name: "research67" }] })
+      if (
+        request.method === "GET" &&
+        url.pathname.endsWith("/runs/run_probe") &&
+        events.some(([event]) => event === "confirm_required")
+      )
+        return Response.json({
+          status: "running",
+          pending_confirmation: { ...events.find(([event]) => event === "confirm_required")![1], status: "pending" },
+        })
       if (url.pathname.endsWith("/run/stream"))
         return new Response(
           events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join(""),
@@ -78,15 +88,27 @@ function rows(result: unknown) {
 
 test("local ID remains accepted", async () => {
   const actual = await execute("ag_research67", [done()])
-  expect(actual.result.output).toBe("ok")
+  expect(actual.result.output).toContain("ok")
   expect(actual.requests[1].path).toBe("/api/v1/agents/ag_research67/run/stream")
 })
-for (const [status, label] of [["needs_evidence", "证据不足"], ["needs_revision", "方案待修订"], ["needs_review", "待人工审阅"]]) {
+for (const [status, label] of [
+  ["needs_evidence", "证据不足"],
+  ["needs_revision", "方案待修订"],
+  ["review_pending", "审阅未完成"],
+  ["needs_review", "待人工审阅"],
+]) {
   test(`execution completion preserves scientific state ${status}`, async () => {
-    const actual = await execute("ag_research67", [["done", {
-      run_id: "run_probe", status: "completed", output: "Research report",
-      research: { revision: 2, science: { status } },
-    }]])
+    const actual = await execute("ag_research67", [
+      [
+        "done",
+        {
+          run_id: "run_probe",
+          status: "completed",
+          output: "Research report",
+          research: { revision: 2, science: { status } },
+        },
+      ],
+    ])
     expect(actual.result.title).toContain(label)
     expect(actual.result.output).toContain("执行结束不代表科学结论成立")
     expect(actual.result.metadata.research).toEqual({ revision: 2, science: { status } })
@@ -94,9 +116,27 @@ for (const [status, label] of [["needs_evidence", "证据不足"], ["needs_revis
 }
 test("remote namespaced ID accepted and encoded as one route segment", async () => {
   const actual = await execute("locostack:science-team:research67.v1", [done()])
-  expect(actual.result.output).toBe("ok")
+  expect(actual.result.output).toContain("ok")
   expect(actual.requests[1].path).toBe("/api/v1/agents/locostack%3Ascience-team%3Aresearch67.v1/run/stream")
 })
+test("conditional diagnostic and insufficient evidence are both visible", async () => {
+  const science = { status: "needs_evidence", execution_status: "conditional_diagnostic_completed" }
+  const actual = await execute("ag_research67", [
+    [
+      "done",
+      {
+        run_id: "run_diagnostic",
+        status: "completed",
+        output: "Bounded model report",
+        research: { science },
+      },
+    ],
+  ])
+  expect(actual.result.title).toBe("证据不足")
+  expect(actual.result.output).toContain("已完成设定模型下的诊断")
+  expect(actual.result.output).toContain("现实适用性须另行判断")
+  expect(actual.result.metadata.research).toEqual({ science })
+})
 for (const id of [
   "ag_",
   "ag_../other",
@@ -115,15 +155,18 @@ for (const id of [
     await expect(execute(id, [done()])).rejects.toThrow("无效 ID")
   })
 }
-test("local confirmation preserves legacy route and approval body", async () => {
+test("local confirmation binds the displayed approval ID on the compatible route", async () => {
   const actual = await execute(
     "ag_research67",
-    [["confirm_required", { run_id: "run_probe", tool: "WriteFile", reason: "overwrite" }], done()],
+    [
+      ["confirm_required", { run_id: "run_probe", approval_id: "tc_local", tool: "WriteFile", reason: "overwrite" }],
+      done(),
+    ],
     false,
   )
   expect(actual.requests.find((r) => r.path.endsWith("/confirm"))).toEqual({
     path: "/api/v1/traces/run_probe/confirm",
-    body: { approved: false },
+    body: { approved: false, approval_id: "tc_local" },
   })
 })
 test("remote confirmation uses owner-aware route and approval ID", async () => {

+ 24 - 9
packages/opencode/test/tool/agentpaas-run.test.ts

@@ -73,6 +73,7 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
       {
         run_id: "run_1",
         tool: "Shell",
+        approval_id: "tc_shell",
         reason: "high-risk command",
         input: "rm generated.tmp",
       },
@@ -90,6 +91,7 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
       {
         run_id: "run_1",
         tool: "WriteFile",
+        approval_id: "tc_write",
         reason: "overwrite existing file",
         input: "report.json",
       },
@@ -191,6 +193,7 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
     globalThis.fetch = Object.assign(
       async (input: URL | RequestInfo, init?: RequestInit) => {
         const pathname = new URL(input instanceof Request ? input.url : input.toString()).pathname
+        if (pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
         if (pathname === "/api/v1/agents") {
           return Response.json({
             agents: [{ id: "ag_pipeline", name: "Research67 三阶段研究草稿流水线" }],
@@ -206,6 +209,14 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
               .join("\n\n") + "\n\n"
           return new Response(body, { headers: { "Content-Type": "text/event-stream" } })
         }
+        if (pathname === "/api/v1/agents/ag_pipeline/runs/run_1")
+          return Response.json({
+            status: "running",
+            pending_confirmation: {
+              approval_id: confirmationBodies.length ? "tc_write" : "tc_shell",
+              status: "pending",
+            },
+          })
         if (pathname === "/api/v1/traces/run_1/confirm") {
           if (typeof init?.body !== "string") throw new Error("confirmation body must be JSON")
           confirmationBodies.push(JSON.parse(init.body))
@@ -406,10 +417,11 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
     expect(permissionRequests).toHaveLength(2)
     expect(permissionRequests[0]).toEqual({
       permission: "agentpaas_confirm",
-      patterns: ["ag_pipeline:Shell"],
-      always: ["ag_pipeline:Shell"],
+      patterns: ["ag_pipeline:run_1:tc_shell"],
+      always: [],
       metadata: {
         run_id: "run_1",
+        approval_id: "tc_shell",
         agent_name: "Research67 三阶段研究草稿流水线",
         tool: "Shell",
         reason: "high-risk command",
@@ -418,11 +430,14 @@ test("shows pipeline stages and resolves remote confirmations", async () => {
     })
     expect(permissionRequests[1]).toMatchObject({
       permission: "agentpaas_confirm",
-      patterns: ["ag_pipeline:WriteFile"],
+      patterns: ["ag_pipeline:run_1:tc_write"],
       metadata: { tool: "WriteFile", reason: "overwrite existing file", input: "report.json" },
     })
-    expect(confirmationBodies).toEqual([{ approved: true }, { approved: false }])
-    expect(result).toMatchObject({ output: "完成" })
+    expect(confirmationBodies).toEqual([
+      { approved: true, approval_id: "tc_shell" },
+      { approved: false, approval_id: "tc_write" },
+    ])
+    expect(result).toMatchObject({ output: expect.stringContaining("完成") })
   } finally {
     globalThis.fetch = originalFetch
     process.env.HOME = originalHome
@@ -510,6 +525,7 @@ test("preserves completed artifacts and forbids automatic reruns after failure",
     globalThis.fetch = Object.assign(
       async (input: URL | RequestInfo) => {
         const pathname = new URL(input instanceof Request ? input.url : input.toString()).pathname
+        if (pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
         if (pathname === "/api/v1/agents") {
           return Response.json({
             agents: [{ id: "ag_pipeline", name: "Research67 三阶段研究草稿流水线" }],
@@ -615,6 +631,7 @@ test("cancels the remote run when the local request is aborted", async () => {
     globalThis.fetch = Object.assign(
       async (input: URL | RequestInfo, init?: RequestInit) => {
         const pathname = new URL(input instanceof Request ? input.url : input.toString()).pathname
+        if (pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
         if (pathname === "/api/v1/agents") {
           return Response.json({
             agents: [{ id: "ag_pipeline", name: "Research67 三阶段研究草稿流水线" }],
@@ -691,6 +708,7 @@ test("reuses AgentPaaS thread and workspace across one OpenCode session", async
     globalThis.fetch = Object.assign(
       async (input: URL | RequestInfo, init?: RequestInit) => {
         const pathname = new URL(input instanceof Request ? input.url : input.toString()).pathname
+        if (pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
         if (pathname === "/api/v1/agents") {
           return Response.json({ agents: [{ id: "ag_research67", name: "research67" }] })
         }
@@ -740,10 +758,7 @@ test("reuses AgentPaaS thread and workspace across one OpenCode session", async
       { input: "继续并查看产物", agent_name: "research67", mode: "chat", conversation: "continue" },
       context,
     )
-    await bridge.execute(
-      { input: "开始新课题", agent_name: "research67", mode: "chat", conversation: "new" },
-      context,
-    )
+    await bridge.execute({ input: "开始新课题", agent_name: "research67", mode: "chat", conversation: "new" }, context)
 
     expect(bodies[0]).toMatchObject({ thread_id: "", context: {} })
     expect(bodies[1]).toMatchObject({ thread_id: "th_research", context: { run_id: "run_1" } })

+ 42 - 1
packages/tui/src/routes/session/index.tsx

@@ -40,6 +40,7 @@ import type {
 import { useLocal } from "../../context/local"
 import { Locale } from "../../util/locale"
 import { webSearchProviderLabel } from "../../util/tool-display"
+import { paasCard } from "../../util/agentpaas"
 import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "../../context/sdk"
 import { useEditorContext } from "../../context/editor"
@@ -1701,11 +1702,16 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
 
 function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
   const ctx = use()
-  const display = createMemo(() => toolDisplay(props.part.tool))
+  const display = createMemo(() =>
+    props.part.tool === "agentpaas_task" && props.part.state.input?.action === "history"
+      ? "generic"
+      : toolDisplay(props.part.tool),
+  )
 
   // Hide tool if showDetails is false and tool completed successfully
   const shouldHide = createMemo(() => {
     if (ctx.showDetails()) return false
+    if (display() === "agentpaas") return false
     if (props.part.state.status !== "completed") return false
     return true
   })
@@ -1731,6 +1737,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
   return (
     <Show when={!shouldHide()}>
       <Switch>
+        <Match when={display() === "agentpaas"}>
+          <PaaSTool {...toolprops} />
+        </Match>
         <Match when={display() === "bash"}>
           <Shell {...toolprops} />
         </Match>
@@ -1788,6 +1797,25 @@ type ToolProps = {
   output?: string
   part: ToolPart
 }
+
+function PaaSTool(props: ToolProps) {
+  const { theme } = useTheme()
+  const [expanded, setExpanded] = createSignal(false)
+  const card = createMemo(() => paasCard(props.metadata, props.input))
+  return (
+    <BlockTool title={`# ${card().title}`} part={props.part} onClick={() => setExpanded((value) => !value)}>
+      <box flexDirection="column">
+        <For each={card().lines}>{(line) => <text fg={theme.text}>{line}</text>}</For>
+        <Show when={props.output}>
+          <text fg={theme.textMuted}>{expanded() ? "点击收起完整结果" : "点击展开完整结果与审批材料"}</text>
+          <Show when={expanded()}>
+            <text fg={theme.text}>{props.output}</text>
+          </Show>
+        </Show>
+      </box>
+    </BlockTool>
+  )
+}
 function GenericTool(props: ToolProps) {
   const { theme } = useTheme()
   const ctx = use()
@@ -2246,6 +2274,14 @@ function Task(props: ToolProps) {
     tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
   )
 
+  const paas = createMemo(() => {
+    const value = current()
+    if (!value || !["agentpaas_run", "agentpaas_task"].includes(value.tool)) return
+    if (value.state.status !== "running" && value.state.status !== "completed") return
+    if (value.state.input?.action === "history") return
+    return paasCard(value.state.metadata ?? {}, value.state.input)
+  })
+
   const progress = createMemo(() => {
     const state = current()?.state
     if (!state || (state.status !== "running" && state.status !== "completed")) return undefined
@@ -2315,6 +2351,10 @@ function Task(props: ToolProps) {
       content.push(`↳ ${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`)
     }
 
+    // A delegated client call may end while its PaaS run is still active.
+    const remote = paas()
+    if (remote) content.push(`↳ ${remote.title}`, `↳ ${remote.lines[0]}`, `↳ ${remote.lines.at(-1)}`)
+
     return content.join("\n")
   })
 
@@ -2792,6 +2832,7 @@ const toolDisplays = new Set([
 ])
 
 export function toolDisplay(tool: string) {
+  if (tool === "agentpaas_run" || tool === "agentpaas_task") return "agentpaas"
   return toolDisplays.has(tool) ? tool : "generic"
 }
 

+ 90 - 0
packages/tui/src/util/agentpaas.ts

@@ -0,0 +1,90 @@
+/** PaaS execution state is independent of the OpenCode tool call finishing. */
+export function paasCard(metadata: Record<string, unknown>, input: Record<string, unknown> = {}) {
+  const status =
+    record(metadata.pending_confirmation)?.status === "pending" && metadata.status === "running"
+      ? "waiting_confirmation"
+      : text(metadata.status) || "unknown"
+  const labels: Record<string, string> = {
+    checking: "执行前检查",
+    queued: "排队中",
+    running: "运行中",
+    resuming: "恢复中",
+    completed: "已完成",
+    partial: "部分完成",
+    needs_clarification: "需要补充信息",
+    paused: "已暂停",
+    waiting_human: "等待人工反馈",
+    waiting_confirmation: "等待工具确认",
+    failed: "失败",
+    cancelled: "已取消",
+    unknown: "状态待核查",
+  }
+  const actions: Record<string, string> = {
+    checking: "正在检查服务与资源",
+    queued: "watch 查看进度",
+    running: "watch 查看进度;cancel 停止任务",
+    resuming: "watch 查看进度",
+    completed: "download 获取产物;需要修订时 select 此任务",
+    partial: "查看缺失项与已有产物",
+    needs_clarification: "回答智能体提出的问题",
+    paused: "resume 从原检查点恢复",
+    waiting_human: "查看材料后 approve/reject",
+    waiting_confirmation: "查看具体操作后 approve/reject",
+    failed: "查看错误与已有产物,不自动重跑",
+    cancelled: "任务已停止",
+    unknown: "用原 run_id 查询;不要重新提交",
+  }
+  const science = record(record(metadata.research)?.science)
+  const usage = record(metadata.usage)
+  const artifacts = Array.isArray(metadata.artifacts)
+    ? metadata.artifacts.flatMap((item) => {
+        const value = record(item)
+        return value && text(value.artifact_id) ? [`${text(value.name) || "文件"} · ${text(value.artifact_id)}`] : []
+      })
+    : []
+  return {
+    title: `${text(metadata.agent_name) || text(input.agent_name) || text(metadata.agent_id) || text(input.agent_id) || "PaaS"} · ${labels[status] || labels.unknown}`,
+    alert: ["failed", "partial", "needs_clarification", "waiting_human", "waiting_confirmation", "unknown"].includes(
+      status,
+    ),
+    lines: [
+      `run_id=${text(metadata.run_id) || "尚未受理"}`,
+      text(metadata.stage_name) || text(metadata.stage_id)
+        ? `当前阶段:${text(metadata.stage_name) || text(metadata.stage_id)}`
+        : "",
+      ...(Array.isArray(metadata.stages)
+        ? metadata.stages.slice(-5).flatMap((item) => {
+            const row = record(item)
+            return row ? [`${text(row.stage_name) || text(row.stage_id)} · ${text(row.status)}`] : []
+          })
+        : []),
+      science ? `研究状态:${text(science.status) || "未报告"};执行结束不代表科学结论成立` : "",
+      record(metadata.research)?.revision !== undefined ? `研究版本:${record(metadata.research)?.revision}` : "",
+      `Tokens: ${number(metadata.total_tokens ?? usage?.total_tokens)} · USD: ${number(metadata.cost_usd ?? usage?.cost_usd)}`,
+      `计量:tokens ${usage?.usage_complete === true ? "完整" : "未确认完整"};费用 ${usage?.cost_complete === true ? "完整" : "未确认完整,0 不代表免费"}`,
+      metadata.cancellation_status
+        ? `取消状态:${({ requested: "待服务端确认", confirmed: "已确认", unconfirmed: "未确认,可能仍在运行", already_terminal: "任务已结束" } as Record<string, string>)[text(metadata.cancellation_status)] || "待核查"}`
+        : "",
+      record(metadata.pending_confirmation)?.expires_at
+        ? `工具确认截止:${text(record(metadata.pending_confirmation)?.expires_at)};超时不会自动批准`
+        : "",
+      metadata.pending_approval
+        ? `人工审阅截止:${text(record(metadata.pending_approval)?.expires_at) || "未指定"}`
+        : "",
+      `产物:${artifacts.length}${artifacts.length ? "(agentpaas_download 下载)" : ""}`,
+      ...artifacts.slice(0, 5),
+      artifacts.length > 5 ? `另有 ${artifacts.length - 5} 项,展开结果查看` : "",
+      `下一步:${actions[status] || actions.unknown}`,
+    ].filter(Boolean),
+  }
+}
+
+function record(value: unknown): Record<string, unknown> | undefined {
+  if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>
+}
+function text(value: unknown) {
+  return typeof value === "string" ? value.replace(/[\r\n\x00-\x1f]/g, " ") : ""
+}
+function number(value: unknown) {
+  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? String(value) : "未报告"
+}

+ 57 - 0
packages/tui/test/util/agentpaas.test.ts

@@ -0,0 +1,57 @@
+import { expect, test } from "bun:test"
+import { paasCard } from "../../src/util/agentpaas"
+
+test("a completed client tool can display a running backend task and unknown billing", () => {
+  const card = paasCard({ status: "running", run_id: "run_long", agent_name: "Data67" })
+  expect(card.title).toBe("Data67 · 运行中")
+  expect(card.lines.join("\n")).toContain("watch")
+  expect(card.lines.join("\n")).toContain("USD: 未报告")
+})
+test("pending ordinary confirmation overrides running and artifacts keep their IDs", () => {
+  const card = paasCard({
+    status: "running",
+    pending_confirmation: { status: "pending" },
+    artifacts: [{ artifact_id: "art_1", name: "结果.csv" }],
+  })
+  expect(card.title).toContain("等待工具确认")
+  expect(card.lines.join("\n")).toContain("approve/reject")
+  expect(card.lines.join("\n")).toContain("结果.csv · art_1")
+})
+test("research revision and scientific insufficiency remain separate from completed execution", () => {
+  const card = paasCard({
+    status: "completed",
+    research: { revision: 2, science: { status: "needs_evidence" } },
+    usage: { total_tokens: 12, cost_usd: 0 },
+  })
+  expect(card.lines.join("\n")).toContain("needs_evidence")
+  expect(card.lines.join("\n")).toContain("研究版本:2")
+  expect(card.lines.join("\n")).toContain("USD: 0")
+})
+
+test("unconfirmed cancellation does not turn a running task into cancelled", () => {
+  const card = paasCard({ status: "running", cancellation_status: "unconfirmed" })
+  expect(card.title).toContain("运行中")
+  expect(card.lines.join("\n")).toContain("未确认,可能仍在运行")
+})
+
+test("approval deadline is visible and incomplete zero cost is not called free", () => {
+  const card = paasCard({
+    status: "running",
+    pending_confirmation: { status: "pending", expires_at: "2026-10-23T10:00:00Z" },
+    usage: { total_tokens: 12, cost_usd: 0 },
+  })
+  expect(card.lines.join("\n")).toContain("2026-10-23T10:00:00Z")
+  expect(card.lines.join("\n")).toContain("0 不代表免费")
+})
+
+test("explicitly complete usage is distinguished from missing completeness", () => {
+  const card = paasCard({
+    status: "completed",
+    usage: { total_tokens: 12, cost_usd: 0.1, usage_complete: true, cost_complete: true },
+  })
+  expect(card.lines.join("\n")).toContain("tokens 完整;费用 完整")
+})
+
+test("a stale confirmation cannot hide a terminal backend state", () => {
+  expect(paasCard({ status: "cancelled", pending_confirmation: { status: "pending" } }).title).toContain("已取消")
+})

+ 226 - 0
script/accept-agentpaas.ts

@@ -0,0 +1,226 @@
+import { mkdir, rename } from "node:fs/promises"
+import path from "node:path"
+import { parseArgs } from "node:util"
+import { asRecord, digest, getString, loadAgentPaaSConfig, canonicalJSON } from "../.opencode/lib/agentpaas"
+import run from "../.opencode/tool/agentpaas_run"
+import { agents, check, upload, task, download } from "../.opencode/tool/agentpaas"
+import { evaluateAcceptance, type AcceptanceCase } from "./lib/agentpaas-acceptance"
+
+const suite = path.resolve(import.meta.dir, "../evals/agentpaas-acceptance")
+const options = parseArgs({
+  options: {
+    run: { type: "boolean" },
+    resume: { type: "boolean" },
+    catalog: { type: "boolean" },
+    case: { type: "string" },
+    "agent-id": { type: "string" },
+    output: { type: "string" },
+    "model-label": { type: "string" },
+    "wait-seconds": { type: "string", default: "120" },
+    "probe-model": { type: "boolean" },
+  },
+}).values
+const cases = (await Bun.file(path.join(suite, "cases.json")).json()) as AcceptanceCase[]
+const context = {
+  sessionID: crypto.randomUUID(),
+  messageID: "acceptance",
+  agent: "paas",
+  directory: suite,
+  worktree: suite,
+  abort: new AbortController().signal,
+  metadata() {},
+  async ask(request: { permission: string }) {
+    if (request.permission !== "agentpaas_upload" && request.permission !== "agentpaas_download")
+      throw new Error("验收脚本不自动批准智能体工具操作,请从 OpenCode 查看并明确决定。")
+  },
+}
+function structured(result: Awaited<ReturnType<typeof run.execute>>) {
+  if (typeof result === "string" || !result.metadata) throw new Error("工具未返回结构化状态")
+  return result
+}
+if (options.catalog) {
+  console.log(structured(await agents.execute({}, context)).output)
+} else if (!options.run && !options.resume) {
+  console.log(
+    JSON.stringify(
+      {
+        mode: "plan_only",
+        cases,
+        instruction:
+          "使用 --catalog 选择实际智能体;--run --case data --agent-id ag_... --model-label 实际模型版本 --output 空目录 启动一个用例。--resume --output 原目录 只观察同一任务。",
+      },
+      null,
+      2,
+    ),
+  )
+} else {
+  if (options.run && options.resume) throw new Error("--run 与 --resume 不能同时使用")
+  if (!options.output) throw new Error("必须指定 --output")
+  const wait = Number(options["wait-seconds"])
+  if (!Number.isInteger(wait) || wait < 0 || wait > 900)
+    throw new Error("wait-seconds 必须为 0–900;到期只停止观察,不取消服务端任务")
+  const config = await loadAgentPaaSConfig()
+  const scope = digest(canonicalJSON([config.url, config.apiKey]))
+  const directory = path.resolve(options.output)
+  const receiptPath = path.join(directory, "receipt.json")
+  const previous = options.resume ? asRecord(await Bun.file(receiptPath).json()) : undefined
+  const sample = cases.find((item) => item.id === (previous?.case_id || options.case))
+  if (!sample) throw new Error("请选择 data、qa、software 或 research 用例")
+  if (previous && previous.scope !== scope) throw new Error("当前服务或账号与原任务不同;停止恢复")
+  if (!previous && (!options["agent-id"] || !options["model-label"]))
+    throw new Error("首次运行必须指定 --agent-id 和 --model-label;标签需与部署配置核对")
+  if (!previous) await mkdir(directory, { recursive: false, mode: 0o700 })
+  const receipt: Record<string, unknown> = previous ?? {
+    case_id: sample.id,
+    agent_id: options["agent-id"],
+    model_label: options["model-label"],
+    model_label_source: "operator_declared",
+    execution_mode: sample.mode,
+    scope,
+    idempotency_key: `acceptance-${crypto.randomUUID()}`,
+    session_id: context.sessionID,
+    started_at: new Date().toISOString(),
+    phase: "prepared",
+  }
+  context.sessionID = getString(receipt.session_id)
+  const save = async () => {
+    await Bun.write(`${receiptPath}.tmp`, JSON.stringify(receipt, null, 2) + "\n", { mode: 0o600 })
+    await rename(`${receiptPath}.tmp`, receiptPath)
+  }
+  await save()
+  try {
+    const agentID = getString(receipt.agent_id)
+    if (!previous) {
+      const probe = structured(
+        await check.execute({ agent_id: agentID, check_model: options["probe-model"] ?? false }, context),
+      )
+      receipt.preflight = probe.metadata?.preflight
+      await save()
+      if (asRecord(receipt.preflight)?.blocked) throw new Error(probe.output)
+      const files = structured(
+        await upload.execute({ paths: sample.files.map((file) => path.join(suite, file)) }, context),
+      )
+      receipt.input_file_ids = (files.metadata?.files as Array<{ file_id: string }>).map((file) => file.file_id)
+      receipt.phase = "submitting"
+      await save()
+      const accepted = structured(
+        await run.execute(
+          {
+            agent_id: agentID,
+            input: sample.prompt,
+            input_file_ids: receipt.input_file_ids as string[],
+            idempotency_key: getString(receipt.idempotency_key),
+            background: true,
+            conversation: "new",
+            mode: sample.mode,
+          },
+          context,
+        ),
+      )
+      receipt.run_id = accepted.metadata?.run_id
+      receipt.phase = "observing"
+      await save()
+    }
+    if (!receipt.run_id && previous && receipt.phase === "submitting") {
+      const recovered = structured(
+        await task.execute(
+          { agent_id: agentID, action: "recover", idempotency_key: getString(receipt.idempotency_key) },
+          context,
+        ),
+      )
+      if (recovered.metadata?.run_id) {
+        receipt.run_id = recovered.metadata.run_id
+        receipt.phase = "observing"
+        await save()
+      }
+    }
+    const runID = getString(receipt.run_id)
+    if (!runID) throw new Error("尚无受理编号;请先用 /paas-tasks 或 history 核查提交结果,禁止重新启动用例")
+    const deadline = Date.now() + wait * 1000
+    let result = structured(await task.execute({ agent_id: agentID, run_id: runID, action: "status" }, context))
+    while (["running", "queued", "resuming"].includes(getString(result.metadata?.status)) && Date.now() < deadline) {
+      result = structured(
+        await task.execute(
+          {
+            agent_id: agentID,
+            run_id: runID,
+            action: "watch",
+            wait_seconds: Math.min(30, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))),
+          },
+          context,
+        ),
+      )
+      if (Array.isArray(result.metadata?.warnings) && result.metadata.warnings.length) break
+    }
+    const metadata = result.metadata!
+    const artifacts = (Array.isArray(metadata.artifacts) ? metadata.artifacts : []).flatMap((item) => {
+      const row = asRecord(item)
+      return row && getString(row.artifact_id) ? [row] : []
+    })
+    const downloads = asRecord(receipt.downloads) ?? {}
+    receipt.downloads = downloads
+    for (const item of artifacts.filter((item) => sample.required_artifacts.includes(getString(item.name)))) {
+      const id = getString(item.artifact_id)
+      if (typeof downloads[id] === "string" && (await Bun.file(downloads[id]).exists())) continue
+      const fetched = structured(
+        await download.execute({ agent_id: agentID, run_id: runID, artifact_id: id }, { ...context, directory }),
+      )
+      downloads[id] = fetched.metadata?.local_path
+      await save()
+    }
+    const target = artifacts.filter((item) => item.name === sample.artifact)
+    const targetPath = target.length === 1 ? getString(downloads[getString(target[0].artifact_id)]) : ""
+    const status = getString(metadata.status)
+    const terminal = ["completed", "partial", "failed", "cancelled", "needs_clarification"].includes(status)
+    const content = targetPath ? await Bun.file(targetPath).text() : undefined
+    const contents: Record<string, string> = {}
+    for (const name of sample.required_artifacts) {
+      const matches = artifacts.filter((item) => item.name === name)
+      const local = matches.length === 1 ? getString(downloads[getString(matches[0].artifact_id)]) : ""
+      if (local) contents[name] = await Bun.file(local).text()
+    }
+    const inputs = Object.fromEntries(
+      await Promise.all(sample.files.map(async (name) => [name, await Bun.file(path.join(suite, name)).text()])),
+    )
+    const evaluation = terminal
+      ? evaluateAcceptance(
+          sample,
+          artifacts.map((item) => getString(item.name)),
+          content,
+          contents,
+          inputs,
+        )
+      : null
+    const summary = {
+      case_id: sample.id,
+      agent_id: agentID,
+      run_id: runID,
+      model_label: receipt.model_label,
+      execution_mode: receipt.execution_mode,
+      usage_complete: asRecord(metadata.usage)?.usage_complete === true,
+      cost_complete: asRecord(metadata.usage)?.cost_complete === true,
+      status,
+      observation_complete: terminal,
+      wall_seconds_since_submission: (Date.now() - Date.parse(getString(receipt.started_at))) / 1000,
+      total_tokens: metadata.total_tokens ?? asRecord(metadata.usage)?.total_tokens ?? null,
+      cost_usd_reported: metadata.cost_usd ?? asRecord(metadata.usage)?.cost_usd ?? null,
+      usage_source: "execution_service; null means unreported; zero is not a verified free-cost claim",
+      automatic_checks: evaluation,
+      correction_rounds: 0,
+      quality_score: null,
+      note: terminal
+        ? "自动检查只验证文件与预定值;人工项未评审,不计算综合质量总分。"
+        : "任务未结束或等待操作。到期不取消、不重跑;处理审批后用 --resume 继续观察。",
+    }
+    await Bun.write(path.join(directory, "result.json"), JSON.stringify(summary, null, 2) + "\n", { mode: 0o600 })
+    await Bun.write(path.join(directory, "result.md"), result.output + "\n", { mode: 0o600 })
+    receipt.phase = terminal ? "finished" : "pending"
+    await save()
+    console.log(JSON.stringify(summary, null, 2))
+  } catch (error) {
+    receipt.error = String(error).replaceAll(config.apiKey, "[redacted]")
+    await save()
+    console.error(receipt.error)
+    process.exitCode = 1
+  }
+}

+ 48 - 0
script/install-agentpaas.ts

@@ -0,0 +1,48 @@
+import { parseArgs } from "node:util"
+import path from "node:path"
+import { mkdir, copyFile } from "node:fs/promises"
+
+const args = parseArgs({ options: { target: { type: "string" }, check: { type: "boolean" } } }).values
+if (!args.target) throw new Error("用法:bun script/install-agentpaas.ts --target /absolute/project [--check]")
+const source = path.resolve(import.meta.dir, "../.opencode")
+const target = path.resolve(args.target, ".opencode")
+if (target === source) throw new Error("目标不能是桥接源码目录")
+const manifest = (await Bun.file(path.join(source, "agentpaas-manifest.json")).json()) as { files: string[] }
+const changed: string[] = []
+for (const name of manifest.files) {
+  if (path.isAbsolute(name) || name.split(/[\\/]/).includes("..")) throw new Error("无效安装清单")
+  const original = Bun.file(path.join(source, name))
+  const existing = Bun.file(path.join(target, name))
+  if (
+    (await existing.exists()) &&
+    Buffer.from(await existing.arrayBuffer()).equals(Buffer.from(await original.arrayBuffer()))
+  )
+    continue
+  changed.push(name)
+}
+if (args.check) {
+  console.log(JSON.stringify({ current: !changed.length, changed }, null, 2))
+  process.exit(changed.length ? 1 : 0)
+}
+const backup = path.join(target, ".agentpaas-backups", `${Date.now()}-${crypto.randomUUID()}`)
+for (const name of changed) {
+  const destination = path.join(target, name)
+  if (await Bun.file(destination).exists()) {
+    await mkdir(path.dirname(path.join(backup, name)), { recursive: true })
+    await copyFile(destination, path.join(backup, name))
+  }
+  await mkdir(path.dirname(destination), { recursive: true })
+  await copyFile(path.join(source, name), destination)
+}
+console.log(
+  JSON.stringify(
+    {
+      installed: target,
+      updated: changed,
+      backup_if_needed: backup,
+      next: "设置 AGENTPAAS_URL 和 AGENTPAAS_API_KEY,使用当前 OpenCode fork 启动 --agent paas,或输入 /paas。不会修改你的模型或凭证配置。",
+    },
+    null,
+    2,
+  ),
+)

+ 119 - 0
script/lib/agentpaas-acceptance.ts

@@ -0,0 +1,119 @@
+import { asRecord, canonicalJSON } from "../../.opencode/lib/agentpaas"
+
+export type AcceptanceCase = {
+  id: string
+  mode: "chat" | "iterate"
+  capability: string
+  files: string[]
+  prompt: string
+  artifact: string
+  expected: Record<string, unknown>
+  required_artifacts: string[]
+  review: string[]
+}
+
+// These are observable contract checks, not an LLM judgment of scientific quality.
+export function evaluateAcceptance(
+  sample: AcceptanceCase,
+  artifacts: string[],
+  content?: string,
+  files: Record<string, string> = {},
+  inputs: Record<string, string> = {},
+) {
+  const checks = sample.required_artifacts.map((name) => ({
+    name: `artifact:${name}`,
+    passed: artifacts.includes(name),
+  }))
+  const parsed =
+    content === undefined
+      ? undefined
+      : (() => {
+          try {
+            return asRecord(JSON.parse(content))
+          } catch {
+            return undefined
+          }
+        })()
+  checks.push({ name: "valid_json_object", passed: parsed !== undefined })
+  for (const [name, expected] of Object.entries(sample.expected))
+    checks.push({
+      name: `value:${name}`,
+      passed: parsed !== undefined && canonicalJSON(parsed[name]) === canonicalJSON(expected),
+    })
+  // Compare downloaded outputs to supplied inputs, never trust the model's own scores.
+  if (sample.id === "data") {
+    const rows = csv(inputs["measurements.csv"])
+    const actual = csv(files["cleaned.csv"])
+    const unique = rows?.filter((row, index, all) => all.findIndex((other) => other.id === row.id) === index)
+    const clean = unique?.filter((row) => row.value.trim() !== "")
+    checks.push({
+      name: "independent:cleaned_rows",
+      passed: !!clean && !!actual && canonicalJSON(actual) === canonicalJSON(clean),
+    })
+    const values = clean?.map((row) => Number(row.value))
+    checks.push({
+      name: "independent:recomputed_statistics",
+      passed:
+        !!rows &&
+        !!unique &&
+        !!clean &&
+        !!values?.length &&
+        values.every(Number.isFinite) &&
+        !!parsed &&
+        parsed.valid_rows === clean.length &&
+        parsed.excluded_missing === unique.length - clean.length &&
+        parsed.duplicates_removed === rows.length - unique.length &&
+        parsed.overall_mean === values.reduce((a, b) => a + b, 0) / values.length &&
+        ["A", "B"].every((group) => {
+          const selected = clean.filter((row) => row.group === group).map((row) => Number(row.value))
+          return (
+            selected.length > 0 &&
+            asRecord(parsed.group_means)?.[group] === selected.reduce((a, b) => a + b, 0) / selected.length
+          )
+        }),
+    })
+  }
+  if (["qa", "research"].includes(sample.id)) {
+    const source = inputs[sample.id === "qa" ? "policy.txt" : "evidence.md"] ?? ""
+    const ids = sample.id === "qa" ? ["P1", "P2", "P3"] : ["E1", "E2", "E3"]
+    const quotes = asRecord(parsed?.evidence_quotes)
+    checks.push({
+      name: "independent:source_quotes",
+      passed:
+        !!quotes &&
+        !!source &&
+        canonicalJSON(Object.keys(quotes).sort()) === canonicalJSON(ids) &&
+        ids.every((id) => {
+          const quote = quotes[id]
+          const line = source.split(/\r?\n/).find((line) => line.startsWith(`[${id}]`) || line.startsWith(`- ${id}:`))
+          return (
+            typeof quote === "string" &&
+            quote.trim().length >= 12 &&
+            !!line?.includes(quote) &&
+            !!files["report.md"]?.includes(quote)
+          )
+        }),
+    })
+    checks.push({
+      name: "independent:evidence_ids",
+      passed:
+        Array.isArray(parsed?.evidence_ids) && canonicalJSON([...parsed.evidence_ids].sort()) === canonicalJSON(ids),
+    })
+  }
+  return {
+    checks,
+    passed: checks.filter((check) => check.passed).length,
+    total: checks.length,
+    human_review: sample.review.map((criterion) => ({ criterion, result: "not_reviewed" })),
+  }
+}
+
+// The fixed data fixture has three unquoted columns; reject ambiguous formats.
+function csv(content?: string) {
+  if (!content) return undefined
+  const lines = content.trim().split(/\r?\n/)
+  if (lines.shift()?.replace(/^\uFEFF/, "") !== "id,group,value") return undefined
+  const rows = lines.map((line) => line.split(","))
+  if (rows.some((row) => row.length !== 3)) return undefined
+  return rows.map(([id, group, value]) => ({ id, group, value }))
+}