@ckanner/dsh-plugin-flywheel 0.0.0-stage → 0.27.3
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 +152 -0
- package/README.md +169 -2
- package/cordis.patch.yml +5 -0
- package/dist/bridge.js +294 -0
- package/dist/client.js +206 -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 +70 -4
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Go ↔ DSH worker 协议 v1
|
|
2
|
+
|
|
3
|
+
此文件是 `internal/drivers/dsh.go` 与本目录 worker 的互操作契约。Go 负责状态机、验收、费用上限及最终进程组清扫;worker 负责一个独立的官方 DSH SDK 进程。两者之间只有本地 stdio,不开放 HTTP 端口。
|
|
4
|
+
|
|
5
|
+
## 启动与输入
|
|
6
|
+
|
|
7
|
+
Go 在工作目录启动 `flywheel-dsh-worker`,写入**一条** UTF-8 JSON 行并关闭 stdin。关闭 stdin 表示请求已完整发送,不表示取消任务。单条输入及 SDK 帧上限均为 8 MiB。stdout 只输出 JSONL,诊断走 stderr。
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{"type":"start","protocol":1,"requestId":"call-123","cwd":"/absolute/worktree","provider":"deepseek-official","model":"deepseek-flash","reasoningEffort":"high","permissionMode":"workspace-write","prompt":"执行冻结工作单元","timeoutMs":1800000,"maxTokens":32768}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
必填字段:`type`、`protocol`、`requestId`、`cwd`、`provider`、`model`、`permissionMode`、`prompt`。`cwd` 必须是存在的绝对目录,worker 规范化为真实路径;Go 在比较前同样规范化。可选字段:
|
|
14
|
+
|
|
15
|
+
| 字段 | 语义 |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `reasoningEffort` | 原样传给 DSH adapter,必须是该模型支持的非空值;省略使用 adapter 默认值 |
|
|
18
|
+
| `maxTokens` | 正整数,每次原生模型请求的输出上限,传入 SDK initialize |
|
|
19
|
+
| `timeoutMs` | 1–86,400,000,默认 1,800,000;涵盖权限准备、初始化、运行和关闭 |
|
|
20
|
+
|
|
21
|
+
`permissionMode` 仅允许 `read-only`、`workspace-write`。未知字段一律拒绝。v1 不接受 `sessionId` / resume、附件、任意额外 CLI 参数和额外写目录;不进行隐式冷启动替代恢复。
|
|
22
|
+
|
|
23
|
+
## 运行参数
|
|
24
|
+
|
|
25
|
+
| 环境变量 | 默认值 | 用途 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `FLYWHEEL_DSH_WORKER` | npm 启动器解析同版本 worker 的绝对路径;独立 Go 二进制为 `flywheel-dsh-worker` | Go driver 使用的 worker 可执行文件路径;用户显式变量优先 |
|
|
28
|
+
| `FLYWHEEL_DSH_BIN` | `dsh` | worker 使用的官方 CLI;支持绝对路径,也可明确依赖 PATH |
|
|
29
|
+
| `FLYWHEEL_DSH_PROFILE` | `sdk` | 已配置的 SDK profile;禁止 `desktop` |
|
|
30
|
+
| `DSH_HOME` | 官方 CLI 默认值 | DSH 自己的配置、授权引用及会话持久化根 |
|
|
31
|
+
|
|
32
|
+
worker 不搜索 `.app` 内部目录、不下载或更新 CLI、不复制桌面授权文件,也不读取或修改 Flywheel 的 `state.json`。Go 的凭证环境白名单仍是外层边界。
|
|
33
|
+
|
|
34
|
+
worker 用 `dsh --profile <profile> --patch <临时覆盖文件>` 启动 DSH。覆盖文件在临时目录内、权限为 `0600`,结束后移除。DSH 子进程继承 Go 建立的 worker 进程组,禁止再为 SDK 创建 detached 组。
|
|
35
|
+
|
|
36
|
+
## 输出帧
|
|
37
|
+
|
|
38
|
+
每帧均含 `protocol:1`、原 `requestId`。顺序是 `ready` → 零或多个 `event` → 恰好一个 `result`;启动失败允许直接 `result`。创建 Session 时的权限事件会缓冲至 ready 之后。
|
|
39
|
+
|
|
40
|
+
### ready:真实能力握手已通过
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{"protocol":1,"requestId":"call-123","type":"ready","sessionId":"flywheel-uuid","cwd":"/absolute/worktree","provider":"deepseek-official","model":"deepseek-flash","reasoningEffort":"high","permissionMode":"workspace-write","approvalPolicy":"never"}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
发出 ready 前必须依次满足:
|
|
47
|
+
|
|
48
|
+
1. 官方 SDK `initialize` 返回 `deepseek-harness-sdk-runtime`,adapter 接受指定 provider/model/effort。
|
|
49
|
+
2. worker 策略插件经真实 `ctx.shell` 执行本地无副作用的 `:` 探针;退出码为 0 且报告 `sandbox.enforcement:full`。不可用或部分实施的沙箱拒绝通过。
|
|
50
|
+
3. `session/working-directory/get` 创建全新的 Session,返回实际 cwd。
|
|
51
|
+
4. Session 的 `sandbox/mode`、`approval/policy` 被固定,`ctx.sandboxPolicy.resolve()` 与 `ctx.approval.effectivePolicy()` 的实际读值形成 `flywheel/policy` 事件。
|
|
52
|
+
5. worker 比对 policy 事件的 cwd、mode、`never` 和 full confinement。
|
|
53
|
+
|
|
54
|
+
任一项缺失,不发送 `session/prompt`。`never` 在 rc.5 的含义是自动拒绝需要批准的提权操作;它不会授予无限权限。
|
|
55
|
+
|
|
56
|
+
### event:可审计的原始持久事件
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{"protocol":1,"requestId":"call-123","type":"event","sessionId":"flywheel-uuid","event":{"seq":12,"type":"turn/end","data":{"turn":0,"reason":{"kind":"completed"}}}}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
只转发该根 Session 的持久事件,按 `seq` 去重。SDK streaming usage 不作重复累加。意外出现根 Session 的子智能体启动时,立即失败并关闭 SDK;编排与委派由 Go 管理。
|
|
63
|
+
|
|
64
|
+
### result:关闭后的唯一结果
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{"protocol":1,"requestId":"call-123","type":"result","status":"ok","settled":true,"sessionId":"flywheel-uuid","text":"完成说明","usage":{"input_tokens":120,"output_tokens":8,"reasoning_tokens":3,"cache_read_tokens":20,"cache_write_tokens":0,"total_tokens":128},"costUsd":null}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
成功必须同时具有:对应 messageId 的持久 inbox receipt、`session/wait` 返回、根 `turn/end.reason.kind:completed`、非空已提交 assistant text、SDK shutdown 确认及退出码 0。idle、超出输出 token 上限、半截文本或仅成功退出都不足以证明成功。
|
|
71
|
+
|
|
72
|
+
`status` 是 `ok`、`error` 或 `cancelled`。失败提供 `error:{code,message}`;code 仅使用 Go 已有分类。`settled` 只描述 SDK 关闭证据;Go watchdog 对整个拥有的进程组做最终判定。强制终止 SDK 或未获得正常 shutdown 确认时,不能返回成功,也不能声称其原生托管进程已全部清理。
|
|
73
|
+
|
|
74
|
+
使用量来自**每条唯一 `assistant/message` 的已提交 per-call usage**,跨该次 worker 调用累加:
|
|
75
|
+
|
|
76
|
+
- DSH 的 `inputTokens` 是未命中缓存的输入;bridge 的 `input_tokens` 是它加已报告 cache read/write 的总输入。Go 不再重复加缓存。
|
|
77
|
+
- `reasoning_tokens` 属于输出的细分项,不再次计入 `total_tokens`。
|
|
78
|
+
- 官方有 `totalTokens` 时保留;没有时按总输入加输出求和。可选细分计数未报告时不额外计入,原始事件保留其缺省事实。
|
|
79
|
+
- 任一已提交 assistant message 缺少有效 usage,整个 `usage` 返回 `null`,不会把不完整的计数当成完整账单。
|
|
80
|
+
- `costUsd` 为 `null`。此桥接没有供应商结算账单,也不把订阅调用计作已知零成本。
|
|
81
|
+
|
|
82
|
+
## 取消、终止及恢复
|
|
83
|
+
|
|
84
|
+
SIGTERM/SIGINT 请求取消。worker 先发送 SDK `shutdown`,再按有界窗口升级至 SIGTERM/SIGKILL 并等待 SDK leader 退出。Go watchdog 在正常退出、取消和超时路径都清扫自己拥有的进程组。DSH 还有自己托管的命令生命周期,因此正常 SDK dispose/exit 是成功收据的一部分;强杀时不能据此宣称所有独立进程组已清理。主动 `setsid` 的进程不在普通进程组保证内。
|
|
85
|
+
|
|
86
|
+
v1 每次调用使用全新 session。Go 重试时重新提交冻结任务输入,已有 worktree 与外部验收证据由 Go 决定是否保留;不会恢复旧 Session 后悄悄更换模型或权限。桌面关闭、窗口隐藏、插件卸载均不会主动停止已经由引擎后台启动的任务;显式 `control stop` 才请求引擎停止并等待清理。
|
|
87
|
+
|
|
88
|
+
`flywheel-dsh-worker --doctor` 接收同一请求格式,只执行初始化与权限握手,然后关闭 SDK,绝不发送模型 prompt。它不能证明凭证、远端推理或终端 UI 已通过真实使用验证。
|
|
89
|
+
|
|
90
|
+
Go 的 `DSHArgs.Doctor` 使用这个入口;为保持 v1 必填 `prompt` 的 admission schema,它会用固定的探针说明替代调用者文本,worker 不将其交给 `session/prompt`。doctor 仍必须具备匹配的 ready、可靠 result、正常 SDK shutdown 与物理退出,不能因为没有推理而放松清理或权限检查。
|
|
91
|
+
|
|
92
|
+
## 桌面控制响应(独立于 worker JSONL)
|
|
93
|
+
|
|
94
|
+
桌面控制层调用 Go 的 `control` 命令,只接收一个 JSON 文档,不把它与上述 worker 流混用。原 `start/status/stop` 字段不变,新增两种 action:
|
|
95
|
+
|
|
96
|
+
| action | 输入与输出 |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `config` | 必需 cwd,可省 taskDir;返回 `configuration` |
|
|
99
|
+
| `doctor` | 必需 cwd,可省 taskDir;可选 `--probe`;返回 `diagnostic` |
|
|
100
|
+
|
|
101
|
+
共同 envelope 为 `protocol:1, ok, running, pid, taskDir, state`,可选 `error`。省略任务时 `taskDir:""`;配置/诊断不查询或修改任务状态,通常 `running:false, pid:0, state:null`,不能用这些字段覆盖面板已经显示的任务运行状态。
|
|
102
|
+
|
|
103
|
+
`configuration` 含 source、scope、cwd、taskDir、bindings。每个 binding 包括 id、role、route、profile、harness、provider、channel、model、thinking、residentSession、permissionMode、inScope。账号渠道与实际 adapter 分开;未给出的旧配置值不得由 UI 猜成已验证。
|
|
104
|
+
|
|
105
|
+
`diagnostic` 含 kind=`doctor`、ok、cwd、taskDir、probeRequested、inferenceTested=`false`、可选 configuration,以及 checks 数组。check 为 id/status/summary,可附 code/remedy;status 只允许 pass/fail/warn/unknown/skipped。配置本身无效时允许没有 configuration,仍返回完整失败检查项。
|
|
106
|
+
|
|
107
|
+
合法检查失败同时满足 exit 1、envelope.ok=false、diagnostic.ok=false;控制层保留并展示报告。其他非零退出、超时、信号、坏 JSON、错误 protocol 或任务身份仍是传输错误。普通 config/doctor 不接受 shell 参数拼接,`--probe` 不能用于其他 action。Go 检查预算 40 秒,TERM 升级、stdio 排空、进程组确认各有 5 秒宽限,保守另留 15 秒;桌面默认控制窗口 60 秒。
|
|
108
|
+
|
|
109
|
+
config/doctor 的 Go CLI 将 SIGTERM/SIGINT 转为 context 取消,等待 watchdog 收尾后才退出。控制调用取消或超过窗口时,host 先发 SIGTERM,诊断动作再留 20 秒让 Go 完成分阶段清理,仍未退出才 SIGKILL;取消始终报告控制失败,即使迟到结果成功也不覆盖它。start/status/stop 保持原控制进程的 500ms 收尾,后台引擎停止仍走独立 stop 协议。
|
|
110
|
+
|
|
111
|
+
## npm 安装报告与桌面入口
|
|
112
|
+
|
|
113
|
+
以下是 Node 安装器的独立契约,不与 worker JSONL、Go control envelope 或任务 `state.json` 混用。入口为 `npx -y @ckanner/flywheel@latest install` 或已安装包的 `flywheel setup`;二者共用参数与行为。普通 npm 包安装没有修改用户目录的 postinstall,只有显式 install/setup 执行 skills、安装记录和 DSH profile 配置。
|
|
114
|
+
|
|
115
|
+
### 安装结果
|
|
116
|
+
|
|
117
|
+
加 `--json` 时 stdout 只包含一个 JSON 文档;安装中的外部命令输出由安装器捕获,不混入此文档。正常进入安装流程后的字段如下:
|
|
118
|
+
|
|
119
|
+
| 字段 | 含义 |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `protocol` | 固定为 `1` |
|
|
122
|
+
| `ok` | 全部请求的软件安装步骤和最终版本核对是否完成;不是账号或推理成功 |
|
|
123
|
+
| `version` | 本次安装器的精确包版本,决定 CLI、平台包、worker 与插件版本 |
|
|
124
|
+
| `steps` | 顺序记录 `global-cli`、`skills`、`desktop-launcher`、`dsh-plugin`、`preflight`;失败时只含已开始的步骤 |
|
|
125
|
+
| `ready` | 本地 doctor 是否无阻断项;诊断未完成时为 `false` |
|
|
126
|
+
| `authentication` | 固定 `unknown`,没有自动登录或真实模型请求 |
|
|
127
|
+
| `prefix`、`paths` | 得到后才记录;paths 包含 CLI、原生二进制、worker 与生成的 launcher 的绝对路径 |
|
|
128
|
+
| `skills` | 安装成功时的数量及准确目录;每项结果保存在 skills 步骤的 `summary` |
|
|
129
|
+
| `diagnostic` | 合法的完整本地 doctor 报告,包括失败检查项;不执行 `--probe` |
|
|
130
|
+
| `error`、`cancelledBy` | 失败原因、可选取消信号;不是成功结果的一部分 |
|
|
131
|
+
|
|
132
|
+
每个 step 具有 `name` 和 `status`,结束时为 `ok`、`warning`、`skipped` 或 `failed`,按步骤附带 `error`、`reason`、`version`、`summary`、`path` 或 `profile`。成功的桌面插件步骤包含 `reloadRequired:true`;宿主仍需按自身流程重新载入。参数解析失败也返回 protocol 1 的单个失败文档,但尚无 `ready`、`authentication` 等运行结果。
|
|
133
|
+
|
|
134
|
+
缺少 Kimi/Codex CLI 或配置未准备好时,合法 doctor 失败报告使安装结果成为 **`ok:true, ready:false`**,preflight 步骤为 warning,安装退出码仍为 0。doctor 必须满足退出码 0/1 与 `diagnostic.ok` 一致;坏 JSON、超时、进程信号或其他退出码都使安装失败,不能伪装成“工具尚未准备好”。
|
|
135
|
+
|
|
136
|
+
安装失败退出码为 1;SIGINT/SIGTERM/SIGHUP 取消按 `128 + signal` 返回,分别为 130/143/129。外部安装步骤有独立时限与进程组收尾窗口,取消不因迟到的成功结果转绿。已完成的 npm/DSH/skills 步骤不自动整体回退,调用者应保留报告、修复具体原因并重跑。
|
|
137
|
+
|
|
138
|
+
`--prefix`、`--project`、`--dsh-bin`、`--profile`、`--no-dsh`、`--no-global`、`--timeout-ms`、`--json` 的完整语义见[教程](../../docs/getting-started.md#2-一条命令安装)。`--no-global` 指当前已经持久安装的包;默认 install 与 setup 都会确保本次版本的全局安装。`--no-dsh` 仅显式跳过插件注册,不把可能失败的本地 doctor 当成已通过。
|
|
139
|
+
|
|
140
|
+
### GUI 安装记录
|
|
141
|
+
|
|
142
|
+
安装器生成 `~/.config/flywheel/installation.json`(0600)与 `npm-launcher`(0755)。记录包含 `protocol:1`、`owner:"@ckanner/flywheel"`、`version`、`prefix`、`cli`、`worker`、`node` 和 `launcher:{path,sha256}`。它只描述软件入口,不保存账号凭证,不构成第二份任务状态。
|
|
143
|
+
|
|
144
|
+
launcher 使用绝对 Node 与 CLI 路径,设置安装时可见的绝对工具 PATH 目录,并在用户没有显式设置 `FLYWHEEL_DSH_BIN` 时使用已核对的桌面命令。npm CLI 启动器解析版本一致的原生包和 worker,缺失或版本不匹配时拒绝继续,不隐式下载另一份二进制。临时 npx 缓存与 `node_modules/.bin` 不被记录成持久工具 PATH。
|
|
145
|
+
|
|
146
|
+
桌面插件按以下顺序选择入口:
|
|
147
|
+
|
|
148
|
+
1. 用户显式 `flywheelBinary`,必须是非空字符串;它优先于安装记录。
|
|
149
|
+
2. 存在安装记录时,验证其协议、owner 和绝对 launcher 路径,再验证 launcher 是普通非链接的可执行文件且 SHA-256 匹配。
|
|
150
|
+
3. 仅当安装记录不存在时,兼容旧配置,从 PATH 查找 `flywheel`。
|
|
151
|
+
|
|
152
|
+
损坏记录、符号链接、缺失或被修改的 launcher 都明确失败,不静默退回 PATH。SHA-256 核对的是 launcher 内容,不是账号状态或所有外部 CLI 的真实性。已有用户修改会在 setup 时保留并报告冲突;修复方法见[升级教程](../../docs/getting-started.md#8-升级排错与卸载)。普通 npm 卸载不会擅自删除安装记录、配置、skills 或任务数据。
|
package/README.md
CHANGED
|
@@ -1,3 +1,170 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Flywheel 的 DSH 桌面入口与原生 worker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
目标是 **DSH Mac 桌面 0.2.1-rc.5** 的已存在接口。Go 继续管理状态机、任务、worktree、验收和成本上限;本包只提供桌面操作入口和原生 SDK 执行通道。无需 alpha、OAR、Forge 或一直活着的父智能体。
|
|
4
|
+
|
|
5
|
+
完整的 npm 安装、SDK 配置到首个任务流程见 [Mac 入门教程](../../docs/getting-started.md),适用于 Flywheel **0.27.3 及以后的完整 npm 发行版**。安装前用 `npm view @ckanner/flywheel version` 确认 registry 的 latest 已更新到所需版本;源码合并或 GitHub Release 不代表 npm 已发行。旧 0.22.1 没有本教程使用的安装与 DSH 接口,当前发行状态见[发行记录](../../docs/releasing.md#3-失败恢复)。
|
|
6
|
+
|
|
7
|
+
## 提供什么
|
|
8
|
+
|
|
9
|
+
| 部分 | 行为 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| 桌面面板 | 会话输入框底部的 **Flywheel** 按钮;配置查看、环境检查、SDK 无推理探测、启动/刷新/停止;显示全部工作单元、逐项诊断与未知计量 |
|
|
12
|
+
| `/flywheel` | 人工 slash command,通过已有 DSH commands Remote 调用 Go CLI;不会将控制指令交给模型执行 |
|
|
13
|
+
| `flywheel_control` | 模型工具,复用同一个校验与 CLI 控制路径;后台 start 很快返回,stop 等待引擎确认 |
|
|
14
|
+
| `flywheel-dsh-worker` | 独立 Node 进程,通过官方 `dsh --profile sdk` stdio JSON-RPC 执行一个原生模型会话 |
|
|
15
|
+
| worker policy | 发请求前验证 cwd、实际权限和完整沙箱,自动拒绝需要批准的扩权 |
|
|
16
|
+
|
|
17
|
+
插件没有自己的任务数据库,不直接读写 `state.json`。桌面退出不会让后台 Go 任务失去父智能体;Go 拥有 worker 生命周期。原生 SDK 入口只接收已注册的 LLM adapter provider;`subagent_codex_pro`、Kimi ACP、Teams 都不是此接口的 provider。独立 Codex / Kimi CLI 使用 Go 的对应 driver。
|
|
18
|
+
|
|
19
|
+
## 一条命令安装到自己的 Mac
|
|
20
|
+
|
|
21
|
+
已有 Node.js 22 或更高、npm 和 DSH Mac 桌面后,先确认 About、桌面安装的 `dsh --version` 与实际运行入口,再运行:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx -y @ckanner/flywheel@latest install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
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,不代登录模型渠道。
|
|
28
|
+
|
|
29
|
+
桌面附带的 `dsh` 命令能管理 `desktop` profile;单独从 npm 安装的 DSH CLI 不能管理这个保留 profile。命令不在 PATH 时加 `--dsh-bin /absolute/path/to/dsh`;不要猜测 `.app/Contents/...` 内部路径。包内 `cordis.patch.yml` 启用 host 插件,`dsh.client` 声明 browser 模块。完成后根据宿主提示重启或重新载入,不假定所有 profile 都支持热更新。
|
|
30
|
+
|
|
31
|
+
`ok:true` 表示软件安装成功。安装后的本地 doctor 仍有阻断项时返回 `ready:false` 和具体检查项,例如 Kimi/Codex 未安装;这不会将已经完成的软件安装伪报为失败,也不证明账号已就绪。安装过程不发送模型 prompt。参数、部分失败恢复与 JSON 语义见[入门教程](../../docs/getting-started.md#2-一条命令安装)和[协议说明](PROTOCOL.md#npm-安装报告与桌面入口)。
|
|
32
|
+
|
|
33
|
+
普通 `npm install --global @ckanner/flywheel` 只安装包文件;随后可显式执行 `flywheel setup --no-global` 配置用户目录和 DSH。没有隐式修改用户配置的 postinstall。单独安装 `@ckanner/dsh-plugin-flywheel` 只得到插件/worker,不是整个 Flywheel 的完整安装。
|
|
34
|
+
|
|
35
|
+
### 自动解析 GUI 入口
|
|
36
|
+
|
|
37
|
+
安装器生成 `~/.config/flywheel/npm-launcher` 及 `installation.json`,记录绝对 Node/CLI 路径、同版 worker、prefix 和 launcher 的 SHA-256。launcher 使用已核对的 DSH 入口与安装时的工具 PATH。默认配置无需手写脚本或修改 profile;桌面插件验证安装记录、普通可执行 launcher 与 hash 后使用。
|
|
38
|
+
|
|
39
|
+
解析顺序是:用户显式 `flywheelBinary` → 有效安装记录 → 仅当记录不存在时从 PATH 查找 `flywheel`。已有记录损坏、launcher 缺失或内容变更时明确失败,不会自动退回另一份引擎。用户自定义配置优先,安装器不会覆盖它。升级 Node、变更安装位置或补齐 CLI 后,可在工具均可见的终端运行 `flywheel setup --no-global`,保留自定义 prefix/DSH 入口参数,再在桌面面板复验。
|
|
40
|
+
|
|
41
|
+
源码开发者或有意使用自定义入口的用户,才需要在桌面 profile 的 `cordis.patch.yml` 显式覆盖,例如:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- id: flywheel
|
|
45
|
+
config:
|
|
46
|
+
flywheelBinary: /absolute/path/to/flywheel
|
|
47
|
+
controlTimeoutMs: 60000
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
这是 DSH 的 profile 配置,不是 Flywheel 的任务状态。使用独立 Go 二进制时应自行保证 worker 与模型 CLI 可见;该高级配置会优先于自动安装记录。`controlTimeoutMs` 保持 60 秒可覆盖引擎 stop 的清理与恢复窗口。
|
|
51
|
+
|
|
52
|
+
## 准备原生 SDK profile
|
|
53
|
+
|
|
54
|
+
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`。
|
|
55
|
+
|
|
56
|
+
桌面有 adapter 不表示 SDK profile 也加载了它。SDK 的 DeepSeek 官方路由可自动挂载,其余 provider 必须在 **SDK profile** 中预先安装并完成该 provider 的授权配置。例如需原生 ChatGPT 路由时,先按该插件的文档检查并安装到对应 profile:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
dsh plugin --profile sdk add dsh-plugin-chatgpt@0.1.17
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
这条命令是配置示例,不会迁移桌面凭证,也不保证一个第三方插件版本自动适配所有宿主。保持用户选择的 `DSH_HOME` 与授权来源一致,按 provider 的正常登录流程配置;不要复制或打印 token。`initialize` 将严格检查请求的 provider、model、effort,未注册的 provider 会失败。
|
|
63
|
+
|
|
64
|
+
实际 Mac rc.5 的 DeepSeek 原生路由是:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
provider = deepseek-official
|
|
68
|
+
model = deepseek-flash
|
|
69
|
+
reasoningEffort = off | low | high | max
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`high` 是默认平衡档,**没有 `medium`**。其他 provider 使用自身的合法 effort。SDK README 的历史示例可能仍写 `deepseek-v4-flash`;以所用发行包与 adapter 的实际 catalog 为准。本桥接不替换模型 slug、不跨 provider 降级。
|
|
73
|
+
|
|
74
|
+
## 无模型请求的 doctor
|
|
75
|
+
|
|
76
|
+
日常使用 Go 命令或面板;Go 会解析实际配置,避免手工输入与任务绑定不一致:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
flywheel config inspect --cwd /repo --task-dir /repo/docs/my-task --json
|
|
80
|
+
flywheel doctor --cwd /repo --task-dir /repo/docs/my-task --json
|
|
81
|
+
flywheel doctor --cwd /repo --task-dir /repo/docs/my-task --probe --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`--task-dir` 可省略:无任务时检查全部绑定,有任务时只检查必要阶段与实际 packet 路由。无 packet 为规划前状态,使用 default 路由并提示规划后复查。`--probe` 才初始化 DSH SDK;普通检查只读取配置、版本和所声明环境变量是否存在。它们不写 Flywheel 状态、全局配置或 worktree,不运行验收,不调用模型。阻断项返回退出码 1 并保留报告;认证/额度/真实模型能力始终未验证。
|
|
85
|
+
|
|
86
|
+
只调试单个 worker 时,仍可在已有且可访问的目标目录生成一条 probe 请求:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
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}))' \
|
|
90
|
+
| flywheel-dsh-worker --doctor
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
预期先看到 ready,再看到 `status:ok` 的 result;doctor 的 `usage` 和 `costUsd` 都是 `null`。该操作会启动本地 SDK、读取 provider 配置、创建本地会话,并运行沙箱中的 `:` 探针;**不会提交推理 prompt**。provider 的模型目录初始化可能联网,不等于发起推理。
|
|
94
|
+
|
|
95
|
+
如返回 `INVALID_REQUEST` 且没有权限确认,先查看 stderr 的真实 SDK 启动诊断。缺少沙箱、部分权限实施、错误 cwd 或插件加载失败都会被拒绝。不要通过 full access 绕过检查。doctor 通过后,再按用户自己的调用授权做一个小型真实任务,验证凭证和远端响应;真实付费模型检查不属于离线默认测试。
|
|
96
|
+
|
|
97
|
+
## 桌面使用
|
|
98
|
+
|
|
99
|
+
打开一个 DSH 会话,点击输入区底部的 **Flywheel**,填写已准备并审阅契约的任务目录,例如 `/repo/docs/my-task`。仓库目录默认取当前 DSH 会话的起始目录,也可填明确的绝对路径。按钮只执行以下 Go JSON 控制接口:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
flywheel control status --task-dir /repo/docs/my-task --cwd /repo
|
|
103
|
+
flywheel control start --task-dir /repo/docs/my-task --cwd /repo
|
|
104
|
+
flywheel control stop --task-dir /repo/docs/my-task --cwd /repo
|
|
105
|
+
flywheel control config --cwd /repo
|
|
106
|
+
flywheel control doctor --task-dir /repo/docs/my-task --cwd /repo --probe
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
等价的人工命令:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
/flywheel {"action":"status","taskDir":"/repo/docs/my-task","cwd":"/repo"}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
模型工具 `flywheel_control` 接受 `action`、`taskDir`、`cwd`,以及仅用于 doctor 的可选 `probe` 布尔值。config/doctor 可省 taskDir,cwd 可沿用会话目录;start/status/stop 必须指定任务。输入统一校验,使用 argv 调用,不拼接 shell 命令。面板只在点击时读取状态,不自动轮询,也不重新决定模型路由。
|
|
116
|
+
|
|
117
|
+
“查看模型配置”展示配置来源、展开后的阶段绑定及 provider/channel;“检查环境”不启动 SDK,“SDK 无推理检查”显式启用 probe。doctor 的合法退出码 1 会显示逐项诊断,超时、坏协议或不匹配的任务身份仍视为控制错误。`ok:true` 不代表账号、额度和真实推理通过。
|
|
118
|
+
|
|
119
|
+
状态区区分未初始化、运行、已完成、暂停、停止、升级和预算停止;全部 packet 可见,不会只展示前几个。未知 usage/cost 明确保留,原始 JSON 默认折叠。改任务/仓库路径会清空旧视图并忽略旧请求的迟到结果。关闭面板、卸载插件或退出桌面,不等于停止任务;重新打开后按“刷新状态”恢复观察,需停止时点“停止”。
|
|
120
|
+
|
|
121
|
+
## 权限、上下文与报告归档
|
|
122
|
+
|
|
123
|
+
每个调用使用新的 SDK Session;provider/model/effort 在该进程中固定。v1 不支持 resume、图像/附件输入或 SDK 原生 output schema。文本与报告文件由 Go 的冻结合同和 JSON Schema 验收,不能把 SDK idle 当成验收完成。
|
|
124
|
+
|
|
125
|
+
worker 只支持 `read-only` 与 `workspace-write`。Mac 上由 DSH 的 Seatbelt bash runner 和 native fs fence 执行权限。workspace-write 的写入范围是**实际工作目录加 DSH 规定的系统临时区**,并非额外目录列表,也不是网络隔离。策略插件将 approval 固定为 `never`,即拒绝提权。
|
|
126
|
+
|
|
127
|
+
执行报告先写到 worktree 内的任务报告路径,由 Go 校验后归档到主任务目录。不得为报告写入而把整个任务目录或用户目录设成 full access。
|
|
128
|
+
|
|
129
|
+
默认覆盖会关闭 DSH 自主委派、workflow、goal 驱动、模型生成标题、web search 工具及自动模型压缩,避免在一个 Go 工作单元之外再启动辅助模型调用。需要压缩/继续执行时由 Go 显式决定下一次任务;超出上下文或输出 token 限制不会伪装成成功。SDK profile 应只装所需的可信 provider /工具,第三方 Host 插件本身是进程内代码,不能由本包替它提供任意代码隔离。
|
|
130
|
+
|
|
131
|
+
## 生命周期与可验证范围
|
|
132
|
+
|
|
133
|
+
官方 rc.5 SDK 没有 per-turn cancel,所以使用每调用一个 SDK 进程。worker 请求 `shutdown` 并等待退出;SDK 自己排空其托管命令。Go 继续负责外层 deadline、取消及它拥有的进程组清扫。正常 SDK 关闭未被证明时结果失败,不能进入成功验收。Go 默认 5 秒 kill grace 应保留,以给 worker 清理窗口;不要配置短于桥接清理预算的值。
|
|
134
|
+
|
|
135
|
+
测试覆盖离线 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)。
|
|
136
|
+
|
|
137
|
+
尚未在用户 Mac 实测:桌面安装和 UI 渲染、Seatbelt 探针、实际 Flash/GPT 推理、授权可见性及断电恢复。Linux 上的 CLI 加载成功不代替这些验证。不会将协议桩写成付费模型成功的证据。
|
|
138
|
+
|
|
139
|
+
完整输入、事件、usage 与关闭语义见 [PROTOCOL.md](PROTOCOL.md)。
|
|
140
|
+
|
|
141
|
+
## 升级、卸载与开发构建
|
|
142
|
+
|
|
143
|
+
升级前停止活动任务、保存配置与自定义内容,再运行同一个 `npx -y @ckanner/flywheel@latest install`,保留原来的 prefix/profile 等参数。skills 使用内容 hash 验证所有权,用户修改会报告冲突并保留;launcher 也不能被静默覆盖。按[冲突恢复步骤](../../docs/getting-started.md#8-升级排错与卸载)处理后重跑,不删除任务账本或修改 hash 绕过校验。
|
|
144
|
+
|
|
145
|
+
卸载先停止活动任务,在 DSH 的所用 profile 移除该插件并重新载入,再执行 `npm uninstall --global @ckanner/flywheel`,自定义 prefix 时加同一参数。npm 卸载不自动删除用户配置、skills、任务文件或安装记录,也不删除模型工具及凭证。
|
|
146
|
+
|
|
147
|
+
开发构建需要 Node.js 22 或更高;Go 引擎还需要 Go 1.27 或更高。生产插件不打包 DSH、React 或第三方运行时;可选 peer 元数据约束桌面接口版本。React 在浏览器端从 DSH 的 ModuleLoader 获取。`npm ci` 安装锁定的 React 18、ReactDOM 与 jsdom **开发依赖**,用于真实 DOM 测试,它们不进入发布包。在仓库根运行:
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
npm --prefix plugins/dsh ci --ignore-scripts
|
|
151
|
+
npm --prefix plugins/dsh run check
|
|
152
|
+
make quality-gate
|
|
153
|
+
npm test
|
|
154
|
+
npm run build:npm
|
|
155
|
+
FLYWHEEL_NPM_PACKAGES_DIR=dist-npm npm run test:install
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`build:npm` 在隔离源码副本中构建 worker 和四个平台 Go 二进制,输出六个同版本 npm 包与候选校验记录,包含 `ckanner-dsh-plugin-flywheel-0.27.3.tgz`。所有包的许可沿用仓库 `UNLICENSED`。tarball/Actions artifact 用于开发或备用验证;正式发布必须使用已经过门禁与真实 npm 安装测试的同一候选,不能在测试后重新编译再发布。上述测试不调用真实模型,也不使用用户账号或桌面 profile。
|
|
159
|
+
|
|
160
|
+
## 实现依据
|
|
161
|
+
|
|
162
|
+
宿主代码按公开 tag `dsh-v0.2.1-rc.5` 固定;另对实际 Mac rc.5 的编译模块核对了同名 API。关键一手来源:
|
|
163
|
+
|
|
164
|
+
- [SDK server 方法、生命周期与限制](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/sdk/server/README.md)
|
|
165
|
+
- [SDK TypeScript 客户端、usage 事件和关闭语义](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/sdk/client/README.md)
|
|
166
|
+
- [SDK bundle 与共享 base 配置](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/bundle/sdk-app/cordis.patch.yml)
|
|
167
|
+
- [官方 CLI profile 与桌面命令边界](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/apps/cli/README.md)
|
|
168
|
+
- [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)
|
|
169
|
+
- [沙箱的单 workspaceRoot、临时区与完整性语义](https://github.com/deepseek-ai/deepseek-harness/blob/b830ec950faba0a46dda564be3e2d854afed94f1/packages/sandbox/sandbox/README.md)
|
|
170
|
+
- [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
|
+
}
|