@ckanner/dsh-plugin-flywheel 0.0.0-stage → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +205 -0
- package/README.en.md +245 -0
- package/README.md +190 -2
- package/cordis.patch.yml +5 -0
- package/dist/bridge.js +294 -0
- package/dist/client.js +206 -0
- package/dist/codex-pro-bridge.js +60 -0
- package/dist/codex-pro-worker.js +117 -0
- package/dist/control.js +128 -0
- package/dist/index.js +74 -0
- package/dist/installation.js +37 -0
- package/dist/policy.js +37 -0
- package/dist/protocol.js +174 -0
- package/dist/worker.js +45 -0
- package/package.json +76 -4
package/README.md
CHANGED
|
@@ -1,3 +1,191 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Flywheel 的 DSH 桌面入口与原生 worker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](README.en.md) · 简体中文
|
|
4
|
+
|
|
5
|
+
目标是 **DSH Mac 桌面 0.2.1-rc.5** 的已存在接口。Go 继续管理状态机、任务、worktree、验收和成本上限;本包提供桌面操作入口、原生 SDK 执行通道和 Codex Pro machine API worker。无需 alpha、OAR、Forge 或一直活着的父智能体。
|
|
6
|
+
|
|
7
|
+
完整的 npm 安装、SDK 配置到首个任务流程见 [Mac 入门教程](../../docs/getting-started.md),适用于 Flywheel **0.28.0 及以后的完整 npm 发行版**。安装前用 `npm view @ckanner/flywheel version` 确认 registry 的 latest 已更新到所需版本;源码合并或 GitHub Release 不代表 npm 已发行。旧 0.22.1 没有本教程使用的安装与 DSH 接口,当前发行状态见[发行记录](../../docs/releasing.md#3-失败恢复)。
|
|
8
|
+
|
|
9
|
+
## 提供什么
|
|
10
|
+
|
|
11
|
+
| 部分 | 行为 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| 桌面面板 | 会话输入框底部的 **Flywheel** 按钮;配置查看、环境检查、SDK 无推理探测、启动/刷新/停止;显示全部工作单元、逐项诊断与未知计量 |
|
|
14
|
+
| `/flywheel` | 人工 slash command,通过已有 DSH commands Remote 调用 Go CLI;不会将控制指令交给模型执行 |
|
|
15
|
+
| `flywheel_control` | 模型工具,复用同一个校验与 CLI 控制路径;后台 start 很快返回,stop 等待引擎确认 |
|
|
16
|
+
| `flywheel-dsh-worker` | 独立 Node 进程,通过官方 `dsh --profile sdk` stdio JSON-RPC 执行一个原生模型会话 |
|
|
17
|
+
| DSH worker policy | 发请求前验证 cwd、实际权限和完整沙箱,自动拒绝需要批准的扩权 |
|
|
18
|
+
| `flywheel-codex-pro-worker` | 直接导入 `dsh-subagent-codex-pro@0.8.0` 的公开 `./machine` 入口,传递任务与取消并返回完整插件回执 |
|
|
19
|
+
|
|
20
|
+
插件没有自己的任务数据库,不直接读写 `state.json`。桌面退出不会让后台 Go 任务失去父智能体;Go 拥有 worker 生命周期。原生 SDK 入口只接收已注册的 LLM adapter provider;`subagent_codex_pro`、Kimi ACP、Teams 都不是此接口的 provider。Kimi CLI、Codex Pro machine API 和旧显式 Codex CLI 使用各自 Go driver;Codex Pro 复用同一插件 runner,不经过 DSH 父模型或工具名转发。
|
|
21
|
+
|
|
22
|
+
## 一条命令安装到自己的 Mac
|
|
23
|
+
|
|
24
|
+
已有 Node.js 22.18.0 或更高、npm 和 DSH Mac 桌面后,先确认 About、桌面安装的 `dsh --version` 与实际运行入口,再运行:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx -y @ckanner/flywheel@latest install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Agent 可以加 `--json` 获取单个结构化结果。该命令全局安装本次精确版本的 CLI、平台 Go 二进制与 worker,安装 10 个内嵌 skills,生成 GUI 启动器,并用现有桌面命令执行 `dsh plugin --profile desktop add --save-exact --ignore-scripts @ckanner/dsh-plugin-flywheel@<本次版本>`,再通过 `plugin list` 验证版本。无需另行安装 worker 或下载压缩包。安装器不替用户安装/更新 DSH,不代登录模型渠道。
|
|
31
|
+
|
|
32
|
+
桌面附带的 `dsh` 命令能管理 `desktop` profile;单独从 npm 安装的 DSH CLI 不能管理这个保留 profile。命令不在 PATH 时加 `--dsh-bin /absolute/path/to/dsh`;不要猜测 `.app/Contents/...` 内部路径。包内 `cordis.patch.yml` 启用 host 插件,`dsh.client` 声明 browser 模块。完成后根据宿主提示重启或重新载入,不假定所有 profile 都支持热更新。
|
|
33
|
+
|
|
34
|
+
`ok:true` 表示软件安装成功。安装后的本地 doctor 仍有阻断项时返回 `ready:false` 和具体检查项,例如 Kimi/Codex 未安装;这不会将已经完成的软件安装伪报为失败,也不证明账号已就绪。安装过程不发送模型 prompt。参数、部分失败恢复与 JSON 语义见[入门教程](../../docs/getting-started.md#2-一条命令安装)和[协议说明](PROTOCOL.md#npm-安装报告与桌面入口)。
|
|
35
|
+
|
|
36
|
+
普通 `npm install --global @ckanner/flywheel` 只安装包文件;随后可显式执行 `flywheel setup --no-global` 配置用户目录和 DSH。没有隐式修改用户配置的 postinstall。单独安装 `@ckanner/dsh-plugin-flywheel` 只得到插件/worker,不是整个 Flywheel 的完整安装。
|
|
37
|
+
|
|
38
|
+
### 自动解析 GUI 入口
|
|
39
|
+
|
|
40
|
+
安装器生成 `~/.config/flywheel/npm-launcher` 及 `installation.json`,记录绝对 Node/CLI 路径、同版 worker、prefix 和 launcher 的 SHA-256。launcher 使用已核对的 DSH 入口与安装时的工具 PATH。默认配置无需手写脚本或修改 profile;桌面插件验证安装记录、普通可执行 launcher 与 hash 后使用。
|
|
41
|
+
|
|
42
|
+
解析顺序是:用户显式 `flywheelBinary` → 有效安装记录 → 仅当记录不存在时从 PATH 查找 `flywheel`。已有记录损坏、launcher 缺失或内容变更时明确失败,不会自动退回另一份引擎。用户自定义配置优先,安装器不会覆盖它。升级 Node、变更安装位置或补齐 CLI 后,可在工具均可见的终端运行 `flywheel setup --no-global`,保留自定义 prefix/DSH 入口参数,再在桌面面板复验。
|
|
43
|
+
|
|
44
|
+
源码开发者或有意使用自定义入口的用户,才需要在桌面 profile 的 `cordis.patch.yml` 显式覆盖,例如:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
- id: flywheel
|
|
48
|
+
config:
|
|
49
|
+
flywheelBinary: /absolute/path/to/flywheel
|
|
50
|
+
controlTimeoutMs: 60000
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
这是 DSH 的 profile 配置,不是 Flywheel 的任务状态。使用独立 Go 二进制时应自行保证 worker 与模型 CLI 可见;该高级配置会优先于自动安装记录。`controlTimeoutMs` 保持 60 秒可覆盖引擎 stop 的清理与恢复窗口。
|
|
54
|
+
|
|
55
|
+
## 准备原生 SDK profile
|
|
56
|
+
|
|
57
|
+
npm CLI 启动器自动解析同版本依赖中的 worker 绝对路径,并在用户未设置时注入 `FLYWHEEL_DSH_WORKER`。直接运行 Go 二进制时默认从 PATH 查找 `flywheel-dsh-worker`;也可显式设置 `FLYWHEEL_DSH_WORKER=/absolute/path/to/dist/worker.js`。自动 launcher 已记录安装时的桌面 `dsh` 入口,用户显式 `FLYWHEEL_DSH_BIN` 优先。`FLYWHEEL_DSH_PROFILE` 默认 `sdk`;可用另一个明确基于 SDK 的独立 profile,但不能是 `desktop`。
|
|
58
|
+
|
|
59
|
+
桌面有 adapter 不表示 SDK profile 也加载了它。SDK 的 DeepSeek 官方路由可自动挂载,其余 provider 必须在 **SDK profile** 中预先安装并完成该 provider 的授权配置。例如需原生 ChatGPT 路由时,先按该插件的文档检查并安装到对应 profile:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
dsh plugin --profile sdk add dsh-plugin-chatgpt@0.1.17
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
这条命令是配置示例,不会迁移桌面凭证,也不保证一个第三方插件版本自动适配所有宿主。保持用户选择的 `DSH_HOME` 与授权来源一致,按 provider 的正常登录流程配置;不要复制或打印 token。`initialize` 将严格检查请求的 provider、model、effort,未注册的 provider 会失败。
|
|
66
|
+
|
|
67
|
+
实际 Mac rc.5 的 DeepSeek 原生路由是:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
provider = deepseek-official
|
|
71
|
+
model = deepseek-flash
|
|
72
|
+
reasoningEffort = off | low | high | max
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`high` 是默认平衡档,**没有 `medium`**。其他 provider 使用自身的合法 effort。SDK README 的历史示例可能仍写 `deepseek-v4-flash`;以所用发行包与 adapter 的实际 catalog 为准。本桥接不替换模型 slug、不跨 provider 降级。
|
|
76
|
+
|
|
77
|
+
## Codex Pro 插件执行通道
|
|
78
|
+
|
|
79
|
+
默认 `hard` 路由使用 `harness: "codex-pro"`。本包精确依赖 `dsh-subagent-codex-pro@0.8.0`,独立 worker 直接导入公开的 `dsh-subagent-codex-pro/machine`,将模型、effort、工作目录和权限传给插件。DSH 控制面、Go 状态机、插件 runner 各自保持原职责;不需要桌面父模型二次委派,也不会自动回退旧 `codex exec`。
|
|
80
|
+
|
|
81
|
+
npm 启动器自动解析并注入 `FLYWHEEL_CODEX_PRO_WORKER`。独立 Go 二进制默认从 PATH 查找 `flywheel-codex-pro-worker`,源码开发可显式设为构建后的 `dist/codex-pro-worker.js`。`FLYWHEEL_CODEX_BIN` 可指定底层 Codex 可执行程序,默认 `codex`;`CODEX_HOME` 沿用用户选择的配置/登录目录。Go 会将这些运行必需项交给插件,不把凭证复制到配置、契约或 Git。
|
|
82
|
+
|
|
83
|
+
新调用固定使用 ephemeral 会话和 `read-only`/`workspace-write`,approval 为 never。插件运行前核对模型、cwd 和权限模式;完整 sandboxPolicy 由 Flywheel 在返回后审查,允许 Codex 标准临时目录、拒绝工作区外额外 roots 与网络启用。0.8.0 尚无公开的运行前完整策略 guard,事后拒绝交付不能阻止先前的访问;可信 Codex 配置仍是本机运行前提。
|
|
84
|
+
|
|
85
|
+
完整 machine receipt 写入指标,保留 accepted effort、未知 effectiveEffort、usage 完整性与进程终态。插件正常收尾可能主动 TERM/KILL app-server,其物理 exit code 可以为空;Flywheel 只有在完整终态证明任务完成、清理已确认且外层 worker 干净退出时才能继续。取消、超时、截断、启动或清理失败均阻止成功。Go 为此 worker 预留至少 10 秒终止宽限,避免截断插件内部清理。
|
|
86
|
+
|
|
87
|
+
仅检查包入口、不调用模型可运行:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
flywheel-codex-pro-worker --doctor
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
成功输出单行协议 1 JSON,精确版本 0.8.0,`available:true`、`inferenceTested:false`。它不启动 Codex app-server,不读取登录凭证,不证明账号或实际沙箱可用。日常仍应使用下面的 Go doctor,以核对完整配置来源与所需阶段。
|
|
94
|
+
|
|
95
|
+
## 无模型请求的 doctor
|
|
96
|
+
|
|
97
|
+
日常使用 Go 命令或面板;Go 会解析实际配置,避免手工输入与任务绑定不一致:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
flywheel config inspect --cwd /repo --task-dir /repo/docs/my-task --json
|
|
101
|
+
flywheel doctor --cwd /repo --task-dir /repo/docs/my-task --json
|
|
102
|
+
flywheel doctor --cwd /repo --task-dir /repo/docs/my-task --probe --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--task-dir` 可省略:无任务时检查全部绑定,有任务时只检查必要阶段与实际 packet 路由。无 packet 为规划前状态,使用 default 路由并提示规划后复查。`--probe` 才初始化 DSH SDK;普通检查只读取配置、版本和所声明环境变量是否存在。它们不写 Flywheel 状态、全局配置或 worktree,不运行验收,不调用模型。阻断项返回退出码 1 并保留报告;认证/额度/真实模型能力始终未验证。
|
|
106
|
+
|
|
107
|
+
只调试单个 worker 时,仍可在已有且可访问的目标目录生成一条 probe 请求:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
node --input-type=module -e 'console.log(JSON.stringify({type:"start",protocol:1,requestId:"local-doctor",cwd:process.cwd(),provider:"deepseek-official",model:"deepseek-flash",reasoningEffort:"high",permissionMode:"read-only",prompt:"This text is not sent in doctor mode",timeoutMs:20000}))' \
|
|
111
|
+
| flywheel-dsh-worker --doctor
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
预期先看到 ready,再看到 `status:ok` 的 result;doctor 的 `usage` 和 `costUsd` 都是 `null`。该操作会启动本地 SDK、读取 provider 配置、创建本地会话,并运行沙箱中的 `:` 探针;**不会提交推理 prompt**。provider 的模型目录初始化可能联网,不等于发起推理。
|
|
115
|
+
|
|
116
|
+
如返回 `INVALID_REQUEST` 且没有权限确认,先查看 stderr 的真实 SDK 启动诊断。缺少沙箱、部分权限实施、错误 cwd 或插件加载失败都会被拒绝。不要通过 full access 绕过检查。doctor 通过后,再按用户自己的调用授权做一个小型真实任务,验证凭证和远端响应;真实付费模型检查不属于离线默认测试。
|
|
117
|
+
|
|
118
|
+
## 桌面使用
|
|
119
|
+
|
|
120
|
+
打开一个 DSH 会话,点击输入区底部的 **Flywheel**,填写已准备并审阅契约的任务目录,例如 `/repo/docs/my-task`。仓库目录默认取当前 DSH 会话的起始目录,也可填明确的绝对路径。按钮只执行以下 Go JSON 控制接口:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
flywheel control status --task-dir /repo/docs/my-task --cwd /repo
|
|
124
|
+
flywheel control start --task-dir /repo/docs/my-task --cwd /repo
|
|
125
|
+
flywheel control stop --task-dir /repo/docs/my-task --cwd /repo
|
|
126
|
+
flywheel control config --cwd /repo
|
|
127
|
+
flywheel control doctor --task-dir /repo/docs/my-task --cwd /repo --probe
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
等价的人工命令:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
/flywheel {"action":"status","taskDir":"/repo/docs/my-task","cwd":"/repo"}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
模型工具 `flywheel_control` 接受 `action`、`taskDir`、`cwd`,以及仅用于 doctor 的可选 `probe` 布尔值。config/doctor 可省 taskDir,cwd 可沿用会话目录;start/status/stop 必须指定任务。输入统一校验,使用 argv 调用,不拼接 shell 命令。面板只在点击时读取状态,不自动轮询,也不重新决定模型路由。
|
|
137
|
+
|
|
138
|
+
“查看模型配置”展示配置来源、展开后的阶段绑定及 provider/channel;“检查环境”不启动 SDK,“SDK 无推理检查”显式启用 probe。doctor 的合法退出码 1 会显示逐项诊断,超时、坏协议或不匹配的任务身份仍视为控制错误。`ok:true` 不代表账号、额度和真实推理通过。
|
|
139
|
+
|
|
140
|
+
状态区区分未初始化、运行、已完成、暂停、停止、升级和预算停止;全部 packet 可见,不会只展示前几个。未知 usage/cost 明确保留,原始 JSON 默认折叠。改任务/仓库路径会清空旧视图并忽略旧请求的迟到结果。关闭面板、卸载插件或退出桌面,不等于停止任务;重新打开后按“刷新状态”恢复观察,需停止时点“停止”。
|
|
141
|
+
|
|
142
|
+
## 权限、上下文与报告归档
|
|
143
|
+
|
|
144
|
+
每个调用使用新的 SDK Session;provider/model/effort 在该进程中固定。v1 不支持 resume、图像/附件输入或 SDK 原生 output schema。文本与报告文件由 Go 的冻结合同和 JSON Schema 验收,不能把 SDK idle 当成验收完成。
|
|
145
|
+
|
|
146
|
+
worker 只支持 `read-only` 与 `workspace-write`。Mac 上由 DSH 的 Seatbelt bash runner 和 native fs fence 执行权限。workspace-write 的写入范围是**实际工作目录加 DSH 规定的系统临时区**,并非额外目录列表,也不是网络隔离。策略插件将 approval 固定为 `never`,即拒绝提权。
|
|
147
|
+
|
|
148
|
+
执行报告先写到 worktree 内的任务报告路径,由 Go 校验后归档到主任务目录。不得为报告写入而把整个任务目录或用户目录设成 full access。
|
|
149
|
+
|
|
150
|
+
默认覆盖会关闭 DSH 自主委派、workflow、goal 驱动、模型生成标题、web search 工具及自动模型压缩,避免在一个 Go 工作单元之外再启动辅助模型调用。需要压缩/继续执行时由 Go 显式决定下一次任务;超出上下文或输出 token 限制不会伪装成成功。SDK profile 应只装所需的可信 provider /工具,第三方 Host 插件本身是进程内代码,不能由本包替它提供任意代码隔离。
|
|
151
|
+
|
|
152
|
+
## 生命周期与可验证范围
|
|
153
|
+
|
|
154
|
+
官方 rc.5 SDK 没有 per-turn cancel,所以使用每调用一个 SDK 进程。worker 请求 `shutdown` 并等待退出;SDK 自己排空其托管命令。Go 继续负责外层 deadline、取消及它拥有的进程组清扫。正常 SDK 关闭未被证明时结果失败,不能进入成功验收。Go 默认 5 秒 kill grace 应保留,以给 worker 清理窗口;不要配置短于桥接清理预算的值。
|
|
155
|
+
|
|
156
|
+
测试覆盖离线 worker 全生命周期与事件折叠;桌面 command/tool 入口一致;构建后的浏览器模块经 ModuleLoader/宿主 React/真实 DOM/Remote 的交互;Go 与 Node 的配置、诊断和失败契约;安装记录校验、用户配置优先及损坏入口拒绝。根目录的 npm 安装测试使用实际六包 tarball、隔离目录和 DSH 协议桩,验证完整安装流程。前轮还核对官方 npm rc.5 的真实 Host commands/tools 注册、SDK 初始化/关闭,以及当前 Linux 无可用沙箱时在 prompt 前 fail closed。具体运行结果见[验证记录](../../docs/validation/2026-11-01-results.md)。
|
|
157
|
+
|
|
158
|
+
尚未在用户 Mac 实测:桌面安装和 UI 渲染、Seatbelt 探针、实际 Flash/GPT 推理、授权可见性及断电恢复。Linux 上的 CLI 加载成功不代替这些验证。不会将协议桩写成付费模型成功的证据。
|
|
159
|
+
|
|
160
|
+
完整输入、事件、usage 与关闭语义见 [PROTOCOL.md](PROTOCOL.md)。
|
|
161
|
+
|
|
162
|
+
## 升级、卸载与开发构建
|
|
163
|
+
|
|
164
|
+
升级前停止活动任务、保存配置与自定义内容,再运行同一个 `npx -y @ckanner/flywheel@latest install`,保留原来的 prefix/profile 等参数。skills 使用内容 hash 验证所有权,用户修改会报告冲突并保留;launcher 也不能被静默覆盖。按[冲突恢复步骤](../../docs/getting-started.md#8-升级排错与卸载)处理后重跑,不删除任务账本或修改 hash 绕过校验。
|
|
165
|
+
|
|
166
|
+
卸载先停止活动任务,在 DSH 的所用 profile 移除该插件并重新载入,再执行 `npm uninstall --global @ckanner/flywheel`,自定义 prefix 时加同一参数。npm 卸载不自动删除用户配置、skills、任务文件或安装记录,也不删除模型工具及凭证。
|
|
167
|
+
|
|
168
|
+
开发构建需要 Node.js 22.18.0 或更高;Go 引擎还需要 Go 1.27 或更高。生产插件精确依赖 `dsh-subagent-codex-pro@0.8.0`;不打包 DSH、React 或 Codex 可执行程序;可选 peer 元数据约束桌面接口版本。React 在浏览器端从 DSH 的 ModuleLoader 获取。`npm ci` 安装锁定的 React 18、ReactDOM 与 jsdom **开发依赖**,用于真实 DOM 测试,它们不进入发布包。在仓库根运行:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
npm --prefix plugins/dsh ci --ignore-scripts
|
|
172
|
+
npm --prefix plugins/dsh run check
|
|
173
|
+
make quality-gate
|
|
174
|
+
npm test
|
|
175
|
+
npm run build:npm
|
|
176
|
+
FLYWHEEL_NPM_PACKAGES_DIR=dist-npm npm run test:install
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`build:npm` 在隔离源码副本中构建 worker 和四个平台 Go 二进制,输出六个同版本 npm 包与候选校验记录,包含 `ckanner-dsh-plugin-flywheel-0.28.0.tgz`。所有包的许可沿用仓库 `UNLICENSED`。tarball/Actions artifact 用于开发或备用验证;正式发布必须使用已经过门禁与真实 npm 安装测试的同一候选,不能在测试后重新编译再发布。上述测试不调用真实模型,也不使用用户账号或桌面 profile。
|
|
180
|
+
|
|
181
|
+
## 实现依据
|
|
182
|
+
|
|
183
|
+
宿主代码按公开 tag `dsh-v0.2.1-rc.5` 固定;另对实际 Mac rc.5 的编译模块核对了同名 API。关键一手来源:
|
|
184
|
+
|
|
185
|
+
- [SDK server 方法、生命周期与限制](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/sdk/server/README.md)
|
|
186
|
+
- [SDK TypeScript 客户端、usage 事件和关闭语义](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/sdk/client/README.md)
|
|
187
|
+
- [SDK bundle 与共享 base 配置](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/bundle/sdk-app/cordis.patch.yml)
|
|
188
|
+
- [官方 CLI profile 与桌面命令边界](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/apps/cli/README.md)
|
|
189
|
+
- [commands 服务](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/interaction/commands/README.md) 与 [tools 服务](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/core/tools/README.md)
|
|
190
|
+
- [沙箱的单 workspaceRoot、临时区与完整性语义](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/sandbox/sandbox/README.md)
|
|
191
|
+
- [ChatGPT adapter 本轮参考版本](https://github.com/ckanner/dsh-plugin-chatgpt/tree/388868f91bcdc05795fb94d21b868ded6d7219c9)
|
package/cordis.patch.yml
ADDED
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { BridgeError, MAX_FRAME_BYTES, normalizedError, record, SessionFold, validateStart } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
export function makeOverlay(request, policyPath = fileURLToPath(new URL('./policy.js', import.meta.url))) {
|
|
10
|
+
// Each patch replaces an entire config row. Pin the known rc.5 enforcement
|
|
11
|
+
// implementations, not the user's mutable permission dropdown defaults.
|
|
12
|
+
const disable = ['permission', 'tool-plugin-manager', 'tool-working-directory',
|
|
13
|
+
'tool-subagent', 'tool-subagent-fork', 'tool-subagent-control', 'tool-subagent-list-agents',
|
|
14
|
+
'subagent-spawn-in-process', 'subagent-fork-in-process', 'tool-workflow',
|
|
15
|
+
'tool-goal', 'goal-round-driver', 'session-title-llm', 'tool-web', 'compaction-basic', 'command-compact', 'hmr'];
|
|
16
|
+
return [
|
|
17
|
+
...disable.map(id => ({ id, disabled: true })),
|
|
18
|
+
{ id: 'sandbox', name: '@deepseek-ai/dsh-sandbox-local', disabled: false, config: {} },
|
|
19
|
+
{ id: 'sandbox-policy', name: '@deepseek-ai/dsh-sandbox-policy', disabled: false,
|
|
20
|
+
config: { mode: request.permissionMode, workspaceRoot: request.cwd } },
|
|
21
|
+
{ id: 'bash-sandbox', name: '@deepseek-ai/dsh-bash-sandbox', disabled: false,
|
|
22
|
+
config: { timeoutMs: 60000, graceMs: 100 } },
|
|
23
|
+
{ id: 'fs-sandbox', name: '@deepseek-ai/dsh-fs-sandbox', disabled: false, config: {} },
|
|
24
|
+
{ id: 'approval', name: '@deepseek-ai/dsh-user-approval', disabled: false, config: { policy: 'never' } },
|
|
25
|
+
{ id: 'sdk-jsonrpc-server', config: { maxTokensAsSuccess: false } },
|
|
26
|
+
{ insert: [{ id: 'flywheel-worker-policy', name: policyPath,
|
|
27
|
+
config: { permissionMode: request.permissionMode, cwd: request.cwd } }] },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function bounded(promise, ms) {
|
|
32
|
+
let timer;
|
|
33
|
+
try {
|
|
34
|
+
return await Promise.race([promise, new Promise(resolve => { timer = setTimeout(resolve, ms); })]);
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function abortable(promise, signal) {
|
|
41
|
+
signal.throwIfAborted();
|
|
42
|
+
let listener;
|
|
43
|
+
try {
|
|
44
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
45
|
+
listener = () => reject(signal.reason);
|
|
46
|
+
signal.addEventListener('abort', listener, { once: true });
|
|
47
|
+
})]);
|
|
48
|
+
} finally {
|
|
49
|
+
signal.removeEventListener('abort', listener);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A bounded JSON-RPC transport. It does not own a new process group. */
|
|
54
|
+
export class Runtime {
|
|
55
|
+
constructor(bin, args, options, notify) {
|
|
56
|
+
this.nextId = 0;
|
|
57
|
+
this.pending = new Map();
|
|
58
|
+
this.buffer = Buffer.alloc(0);
|
|
59
|
+
this.failed = null;
|
|
60
|
+
this.protocolFailure = null;
|
|
61
|
+
this.shutdownAcknowledged = false;
|
|
62
|
+
this.exit = null;
|
|
63
|
+
this.closed = false;
|
|
64
|
+
this.child = spawn(bin, args, { ...options, detached: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
65
|
+
this.exited = new Promise(resolve => { this.resolveExit = resolve; });
|
|
66
|
+
this.child.on('error', error => {
|
|
67
|
+
this.fail(new BridgeError('TRANSPORT', `cannot start DSH: ${error.message}`));
|
|
68
|
+
if (this.child.pid === undefined) {
|
|
69
|
+
this.exit = { code: null, signal: null };
|
|
70
|
+
this.resolveExit(this.exit);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
this.child.on('exit', (code, signal) => {
|
|
74
|
+
this.exit = { code, signal };
|
|
75
|
+
});
|
|
76
|
+
this.child.on('close', (code, signal) => {
|
|
77
|
+
this.closed = true;
|
|
78
|
+
if (this.buffer.length) this.fail(new BridgeError('TRANSPORT', 'DSH stdout ended in an incomplete frame'));
|
|
79
|
+
this.fail(new BridgeError('TRANSPORT', `DSH transport closed (exit=${code}, signal=${signal ?? 'none'})`));
|
|
80
|
+
this.resolveExit(this.exit ?? { code, signal });
|
|
81
|
+
});
|
|
82
|
+
this.child.stdin.on('error', error => this.fail(new BridgeError('TRANSPORT', `DSH stdin: ${error.message}`)));
|
|
83
|
+
this.child.stderr.on('data', chunk => {
|
|
84
|
+
// Logs stay separate from the engine's protocol. No prompt/credential dump.
|
|
85
|
+
options.stderr?.(chunk);
|
|
86
|
+
});
|
|
87
|
+
this.child.stdout.on('data', chunk => {
|
|
88
|
+
try {
|
|
89
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
90
|
+
let newline;
|
|
91
|
+
while ((newline = this.buffer.indexOf(10)) !== -1) {
|
|
92
|
+
if (newline > MAX_FRAME_BYTES) throw new BridgeError('TRANSPORT', 'DSH frame exceeds 8 MiB');
|
|
93
|
+
const line = this.buffer.subarray(0, newline).toString('utf8');
|
|
94
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
95
|
+
if (line.trim() === '') continue;
|
|
96
|
+
const message = JSON.parse(line);
|
|
97
|
+
if (!record(message) || message.jsonrpc !== '2.0') {
|
|
98
|
+
throw new BridgeError('TRANSPORT', 'DSH stdout is not JSON-RPC 2.0');
|
|
99
|
+
}
|
|
100
|
+
if (Object.hasOwn(message, 'id')) {
|
|
101
|
+
if (message.id === 'flywheel-shutdown') {
|
|
102
|
+
this.shutdownAcknowledged = !message.error && Object.hasOwn(message, 'result');
|
|
103
|
+
}
|
|
104
|
+
const waiting = this.pending.get(message.id);
|
|
105
|
+
if (!waiting) continue; // Includes the best-effort shutdown acknowledgement.
|
|
106
|
+
this.pending.delete(message.id);
|
|
107
|
+
if (message.error) {
|
|
108
|
+
waiting.reject(new BridgeError('TRANSPORT', `${waiting.method}: ${message.error.message ?? 'JSON-RPC error'}`));
|
|
109
|
+
} else if (Object.hasOwn(message, 'result')) waiting.resolve(message.result);
|
|
110
|
+
else waiting.reject(new BridgeError('TRANSPORT', 'DSH response has no result or error'));
|
|
111
|
+
} else if (typeof message.method === 'string') notify(message.method, message.params);
|
|
112
|
+
else throw new BridgeError('TRANSPORT', 'malformed DSH notification');
|
|
113
|
+
}
|
|
114
|
+
if (this.buffer.length > MAX_FRAME_BYTES) throw new BridgeError('TRANSPORT', 'DSH frame exceeds 8 MiB');
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.protocolFailure = error instanceof BridgeError ? error : new BridgeError('TRANSPORT', `invalid DSH frame: ${error.message}`);
|
|
117
|
+
this.buffer = Buffer.alloc(0);
|
|
118
|
+
this.fail(this.protocolFailure);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fail(error) {
|
|
124
|
+
this.failed ??= error;
|
|
125
|
+
for (const waiter of this.pending.values()) waiter.reject(this.failed);
|
|
126
|
+
this.pending.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
request(method, params) {
|
|
130
|
+
if (this.failed) return Promise.reject(this.failed);
|
|
131
|
+
if (this.exit) return Promise.reject(new BridgeError('TRANSPORT', 'DSH already exited'));
|
|
132
|
+
const id = ++this.nextId;
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
this.pending.set(id, { resolve, reject, method });
|
|
135
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async close() {
|
|
140
|
+
// The Go watchdog owns the whole inherited process group. We request the
|
|
141
|
+
// SDK's child-first drain and bound the leader wait; Go performs the final
|
|
142
|
+
// group sweep even on normal exit. A child must not detach/setsid.
|
|
143
|
+
let forced = false;
|
|
144
|
+
if (!this.exit && !this.child.stdin.destroyed) {
|
|
145
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 'flywheel-shutdown', method: 'shutdown', params: {} }) + '\n');
|
|
146
|
+
await bounded(this.exited, 400);
|
|
147
|
+
}
|
|
148
|
+
if (!this.exit) {
|
|
149
|
+
forced = true;
|
|
150
|
+
this.child.stdin.end();
|
|
151
|
+
this.child.kill('SIGTERM');
|
|
152
|
+
await bounded(this.exited, 250);
|
|
153
|
+
}
|
|
154
|
+
if (!this.exit) {
|
|
155
|
+
this.child.kill('SIGKILL');
|
|
156
|
+
await bounded(this.exited, 350);
|
|
157
|
+
}
|
|
158
|
+
if (!this.exit) throw new BridgeError('TRANSPORT', 'DSH leader did not exit during bounded shutdown');
|
|
159
|
+
if (!this.closed && this.child.pid !== undefined) {
|
|
160
|
+
await bounded(this.exited, 350);
|
|
161
|
+
if (!this.closed) throw new BridgeError('TRANSPORT', 'DSH exited but its protocol streams did not reach quiescence');
|
|
162
|
+
}
|
|
163
|
+
return { ...this.exit, forced, graceful: this.shutdownAcknowledged, spawned: this.child.pid !== undefined };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runWorker(input, { emit, env = process.env, signal, doctor = false, stderr } = {}) {
|
|
168
|
+
let request;
|
|
169
|
+
let runtime;
|
|
170
|
+
let temp;
|
|
171
|
+
let sessionId;
|
|
172
|
+
let fold;
|
|
173
|
+
let outcome;
|
|
174
|
+
let failure;
|
|
175
|
+
let cancelled = false;
|
|
176
|
+
let settled = true;
|
|
177
|
+
let readySent = false;
|
|
178
|
+
const beforeReady = [];
|
|
179
|
+
let deadline;
|
|
180
|
+
const started = Date.now();
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const cancel = () => {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
controller.abort(new BridgeError('TRANSPORT', 'worker cancelled'));
|
|
185
|
+
};
|
|
186
|
+
const abort = () => runtime?.fail(controller.signal.reason);
|
|
187
|
+
controller.signal.addEventListener('abort', abort);
|
|
188
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
189
|
+
const frame = value => emit({ protocol: 1, requestId: request?.requestId ?? input?.requestId ?? 'invalid-request', ...value });
|
|
190
|
+
try {
|
|
191
|
+
const budget = Number.isSafeInteger(input?.timeoutMs) && input.timeoutMs > 0 && input.timeoutMs <= 86400000
|
|
192
|
+
? input.timeoutMs : 30 * 60 * 1000;
|
|
193
|
+
deadline = setTimeout(() => controller.abort(new BridgeError('TIMEOUT', 'DSH worker lifecycle deadline exceeded')), budget);
|
|
194
|
+
if (signal?.aborted) cancel();
|
|
195
|
+
request = await abortable(validateStart(input), controller.signal);
|
|
196
|
+
controller.signal.throwIfAborted();
|
|
197
|
+
const bin = env.FLYWHEEL_DSH_BIN || 'dsh';
|
|
198
|
+
if (bin.includes('\0') || bin.startsWith('-')) throw new BridgeError('INVALID_REQUEST', 'invalid FLYWHEEL_DSH_BIN');
|
|
199
|
+
const profile = env.FLYWHEEL_DSH_PROFILE || 'sdk';
|
|
200
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile) || profile === 'desktop') {
|
|
201
|
+
throw new BridgeError('INVALID_REQUEST', 'FLYWHEEL_DSH_PROFILE must name an SDK profile, not desktop');
|
|
202
|
+
}
|
|
203
|
+
temp = await mkdtemp(join(tmpdir(), 'flywheel-dsh-'));
|
|
204
|
+
const patch = join(temp, 'worker.patch.json');
|
|
205
|
+
await writeFile(patch, JSON.stringify(makeOverlay(request)), { mode: 0o600 });
|
|
206
|
+
controller.signal.throwIfAborted();
|
|
207
|
+
sessionId = `flywheel-${randomUUID()}`;
|
|
208
|
+
fold = new SessionFold(sessionId);
|
|
209
|
+
runtime = new Runtime(bin, ['--profile', profile, '--patch', patch], {
|
|
210
|
+
cwd: request.cwd,
|
|
211
|
+
env: { ...env, DSH_PERMISSION_MODE: request.permissionMode,
|
|
212
|
+
DSH_MAX_TOKENS_AS_SUCCESS: 'false', DSH_TELEMETRY_DISABLED: '1', FLYWHEEL_DSH_NATIVE_WORKER: '1' },
|
|
213
|
+
stderr,
|
|
214
|
+
}, (method, params) => {
|
|
215
|
+
if (method === 'session.event' && fold.accept(params)) {
|
|
216
|
+
const eventFrame = { type: 'event', sessionId, event: params.event };
|
|
217
|
+
if (readySent) frame(eventFrame);
|
|
218
|
+
else {
|
|
219
|
+
beforeReady.push(eventFrame);
|
|
220
|
+
if (beforeReady.length > 256) throw new BridgeError('TRANSPORT', 'excessive SDK events before readiness');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (method === 'subagent.started' && params?.parentSessionId === sessionId) {
|
|
224
|
+
throw new BridgeError('INVALID_REQUEST', 'DSH child delegation is disabled: the Go engine owns workers');
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
if (controller.signal.aborted) runtime.fail(controller.signal.reason);
|
|
228
|
+
const initialized = await runtime.request('initialize', {
|
|
229
|
+
cwd: request.cwd, provider: request.provider, model: request.model,
|
|
230
|
+
...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
|
|
231
|
+
...(request.maxTokens === undefined ? {} : { maxTokens: request.maxTokens }),
|
|
232
|
+
});
|
|
233
|
+
if (initialized?.serverInfo?.name !== 'deepseek-harness-sdk-runtime') {
|
|
234
|
+
throw new BridgeError('TRANSPORT', 'unexpected SDK runtime identity');
|
|
235
|
+
}
|
|
236
|
+
// This creates the fresh Session and its permission facts without an LLM turn.
|
|
237
|
+
const working = await runtime.request('session/working-directory/get', { sessionId });
|
|
238
|
+
if (working?.cwd !== request.cwd) throw new BridgeError('INVALID_REQUEST', 'DSH effective cwd differs from the requested workspace');
|
|
239
|
+
fold.verifiedPolicy(request);
|
|
240
|
+
frame({ type: 'ready', sessionId, cwd: request.cwd, provider: request.provider, model: request.model,
|
|
241
|
+
...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
|
|
242
|
+
permissionMode: request.permissionMode, approvalPolicy: 'never' });
|
|
243
|
+
readySent = true;
|
|
244
|
+
for (const eventFrame of beforeReady) frame(eventFrame);
|
|
245
|
+
beforeReady.length = 0;
|
|
246
|
+
if (doctor) {
|
|
247
|
+
outcome = { text: 'SDK route, cwd and permission handshake verified; no model prompt sent.', usage: null };
|
|
248
|
+
} else {
|
|
249
|
+
const receipt = await runtime.request('session/prompt', {
|
|
250
|
+
sessionId, contentBlocks: [{ type: 'text', text: request.prompt }],
|
|
251
|
+
});
|
|
252
|
+
if (typeof receipt?.messageId !== 'string' || receipt.messageId === '') {
|
|
253
|
+
throw new BridgeError('TRANSPORT', 'SDK prompt returned no messageId');
|
|
254
|
+
}
|
|
255
|
+
await runtime.request('session/wait', { sessionId });
|
|
256
|
+
outcome = fold.finish(receipt.messageId);
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
failure = normalizedError(error);
|
|
260
|
+
} finally {
|
|
261
|
+
if (runtime) {
|
|
262
|
+
try {
|
|
263
|
+
const exit = await runtime.close();
|
|
264
|
+
if (exit.spawned && (exit.forced || exit.code !== 0 || exit.signal || !exit.graceful)) {
|
|
265
|
+
settled = false;
|
|
266
|
+
failure ??= { code: 'TRANSPORT', message: 'DSH runtime did not prove graceful shutdown of its managed processes' };
|
|
267
|
+
}
|
|
268
|
+
if (runtime.protocolFailure) failure ??= normalizedError(runtime.protocolFailure);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
settled = false;
|
|
271
|
+
failure = normalizedError(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
clearTimeout(deadline);
|
|
275
|
+
if (controller.signal.aborted) failure ??= normalizedError(controller.signal.reason);
|
|
276
|
+
if (request && Date.now() - started > request.timeoutMs && !cancelled) {
|
|
277
|
+
failure = { code: 'TIMEOUT', message: 'DSH worker lifecycle deadline exceeded (including cleanup)' };
|
|
278
|
+
}
|
|
279
|
+
if (temp) await rm(temp, { recursive: true, force: true }).catch(error => {
|
|
280
|
+
failure ??= { code: 'TRANSPORT', message: `could not remove worker overlay: ${error.message}` };
|
|
281
|
+
});
|
|
282
|
+
signal?.removeEventListener('abort', cancel);
|
|
283
|
+
controller.signal.removeEventListener('abort', abort);
|
|
284
|
+
}
|
|
285
|
+
const result = {
|
|
286
|
+
type: 'result', status: cancelled ? 'cancelled' : failure ? 'error' : 'ok', settled,
|
|
287
|
+
...(sessionId ? { sessionId } : {}),
|
|
288
|
+
text: outcome?.text ?? fold?.text ?? '',
|
|
289
|
+
usage: outcome?.usage ?? null, costUsd: null,
|
|
290
|
+
...(failure ? { error: failure } : {}),
|
|
291
|
+
};
|
|
292
|
+
frame(result);
|
|
293
|
+
return result;
|
|
294
|
+
}
|