@mars.liu/dsh-cybernetics 0.6.0 → 0.7.8
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/README.md +93 -0
- package/docs/issue-browser-connection-pool.md +79 -0
- package/docs/listBundles-hang-investigation-20261103.md +93 -0
- package/docs/protocol.md +35 -1
- package/lib/client.js +286 -2
- package/package.json +6 -2
- package/src/controller.ts +80 -0
- package/src/index.ts +173 -14
package/README.md
CHANGED
|
@@ -20,6 +20,99 @@ task agent → verify agent → triage agent,controller 在轮间推进,用
|
|
|
20
20
|
|
|
21
21
|
## 状态
|
|
22
22
|
|
|
23
|
+
v0.7:**全量 goal 模式(`allGoalsMode`,默认 false)**——开启后注入「ALL-GOALS MODE ACTIVE」
|
|
24
|
+
路由协议:任何用户请求一律先建 goal 再执行(禁止就地分析);简单只读 → quick 单轮;
|
|
25
|
+
复杂分析/写任务 → full 多轮渐进(start 异步 + collect 收敛);revise 缺省 `abortPrevious`
|
|
26
|
+
改为自动决策树(查 goal 板 running 状态:旧轮在跑且 objective/depth 被改写 → 自动中止;
|
|
27
|
+
旧轮在跑但目标不变 → 等待自然结束;旧轮未跑 → 直启),显式传值仍是人工控制。
|
|
28
|
+
每次修订的 `revisions-<新goalId>.jsonl` 审计行新增 `abort: {abort, mode, basis}`;
|
|
29
|
+
`revise` 返回值同样携带 `abort`。决策树纯函数在 `src/controller.ts` 的 `decideAbort`。
|
|
30
|
+
关闭模式时提示段与 revise 行为与 v0.6 完全一致(离线测试 48–50 锁死)。`src/` 改动需宿主重载。
|
|
31
|
+
v0.7.1:**插件配置面板可视化配置**——`src/index.ts` 导出 schemastery `Config` schema,宿主
|
|
32
|
+
0.1.7 settings model 据此派生 Web 编辑器插件条目的设置页(机制同 `@linxin666/dsh-ssh` /
|
|
33
|
+
`@linxin666/dsh-usage`:loader 经 `runtime.Config['~standard'].validate` 解析配置,面板由
|
|
34
|
+
dsh-client-ui-settings 的共享配置表单渲染,含每个字段的 `description` 文案)。
|
|
35
|
+
`allGoalsMode` 标记 `.volatile()`:在面板上切换保存后 loader 直接把新值提交进运行实例
|
|
36
|
+
(不 remount),提示段经 `loader/volatile-update` 重挂即时生效;`hardGate`/`gateMode`/
|
|
37
|
+
`gatedTools`/`roles` 等结构性字段为普通字段,保存后触发插件行 remount(需宿主重载完成)。
|
|
38
|
+
注意:schema 解析会把未设置的 `z.array` 字段变成 `[]` 而非 `undefined`,`apply()` 的门
|
|
39
|
+
回退因此按「空数组 = 未设置」处理(离线测试 51 锁死)。此前版本只能手改 profile JSON
|
|
40
|
+
里的 `allGoalsMode: true`;现在在 Web 编辑器「插件」页的本插件卡片上直接切换
|
|
41
|
+
即可(保存 volatile 字段无需重启宿主,刷新浏览器可见最新面板)。
|
|
42
|
+
v0.7.2/0.7.3:**插件页设置卡片**——实测仅有服务端 Config schema 不会在「插件」页产生任何
|
|
43
|
+
卡片(本 profile 的 @linxin666/dsh-client-ui-plugin-manager 停用了官方 ui-settings-plugins
|
|
44
|
+
设置分区,schema 自动面板无处落脚)。官方插件页(@deepseek-ai/dsh-client-ui-plugin-manager)
|
|
45
|
+
只通过它声明的 slot 渲染插件配置:`plugins.item`(「内置插件配置」分组列表卡)、
|
|
46
|
+
`plugins.bundle.config`(按 bundle 名分发,bundle 详情页配置区)、`plugins.row.config`
|
|
47
|
+
(按 `<bundle>#<rowId>` 分发,组件详情页)。`lib/client.js` 因此注册三个席位:
|
|
48
|
+
`plugins.item`(id = `dsh-cybernetics`)、`plugins.bundle.config`(key 同时注册
|
|
49
|
+
profile 依赖名 `dsh-cybernetics` 与包名 `@mars.liu/dsh-cybernetics`)、`plugins.row.config`
|
|
50
|
+
(key `<bundle>#dsh-cybernetics`)。`plugins.item` 无条件注册(v0.7.5 加固):早先的
|
|
51
|
+
cut 用 `configForms.whileServed` 门控,宿主尚未服务该命名空间时整张卡消失,与「注册
|
|
52
|
+
从未运行」不可区分;现改为 `ctx.configForms.get()` 绑定一个跟随共享 describe mirror
|
|
53
|
+
的活 ConfigFormController,命名空间就绪的瞬间卡片翻成开关,未就绪时渲染
|
|
54
|
+
「配置不可用」行——可见、可诊断。卡片读写 `ctx.configForms.get('dsh-cybernetics')`
|
|
55
|
+
的 ConfigForm;v0.7.3 修复:详情页的 owner prop `form`(宿主 ConfigPageForm
|
|
56
|
+
`{state, mutate}`)在渲染 seam 上覆盖 inject 的 form(renderer renderEntry 先展开
|
|
57
|
+
inject 后展开 ownerProps),卡片两种形态都支持(离线测试 52/53 锁死)。
|
|
58
|
+
`dsh.client.inject` 增补 `@deepseek-ai/dsh-client-ui-settings` 保证 configForms 提供方
|
|
59
|
+
先到。改动 `lib/client.js` 后需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器。
|
|
60
|
+
v0.7.4:**保存失败可见化**——实机验证(2026-11-03,浏览器实测):卡片注册、summary 文案、开关渲染
|
|
61
|
+
均正常,但切换保存经 `configForms`→`remote.settings.mutate` 到宿主后被**静默拒绝**(写不落盘:
|
|
62
|
+
profile `cordis.patch.yml` 未变更,RPC 错误不上控制台,卡片此前 `.catch(() => {})` 吞掉失败,
|
|
63
|
+
第一次点击甚至把 promise 挂死在「保存中」)。离线模拟宿主 `dsh-config-editor.edit` 的文档校验
|
|
64
|
+
(`composeEntries([patches])` 含 bundle insert 层 + 新增 override 行)是**通过**的,说明拒绝发生在
|
|
65
|
+
更早的环节(`reconcileProfilePatches` 预检 / loader 热重载路径),宿主日志在本环境不可读,确切
|
|
66
|
+
拒绝原因待宿主侧确认。本轮改动:卡片不再吞错——写入被拒(settles false / reject)或 15 s 未返回
|
|
67
|
+
都显示「保存未生效(宿主拒绝了写入)」并立即把开关还原到宿主确认的快照值;成功路径语义不变
|
|
68
|
+
(volatile 即时生效)。根治写路径需要宿主侧 settings.mutate 的拒绝日志(`harness-log` 或
|
|
69
|
+
`dsh plugin --profile web` 诊断),超出本轮插件仓库边界。
|
|
70
|
+
v0.7.5:**卡片注册无条件化**——见上 v0.7.2/0.7.3 段的 `plugins.item` 说明;
|
|
71
|
+
宿主日志复核(2026-11-03,`~/.dsh/logs/`):未发现 cybernetics settings mutate 的拒绝
|
|
72
|
+
记录(最近一次启动日志是 EADDRINUSE 的失败实例,当前宿主进程无对应落盘日志),
|
|
73
|
+
写路径的宿主侧根因仍待带日志的复现。
|
|
74
|
+
v0.7.6:**路径 (b) 落点取证 + 拒绝原因透出**——代码级取证(2026-11-04,宿主 checkout 只读)确认:
|
|
75
|
+
`settings.mutate` 的唯一宿主写路径是 `@deepseek-ai/dsh-settings SettingsForms.write` →
|
|
76
|
+
`@deepseek-ai/dsh-config-editor ConfigEditor.edit`,后者写入的就是**用户 patch 行**(在 profile
|
|
77
|
+
`cordis.patch.yml` 追加/更新 `{id, name, config}` 非 insert 行,再以
|
|
78
|
+
`reconcileProfilePatches(root, patches, "dsh", [entry.id])` 应用——见
|
|
79
|
+
`dsh-config-editor/lib/index.js` edit() 的 `findLastIndex` + `document.add({id, name, config})`)。
|
|
80
|
+
即插件侧写入落点**从来就是**宿主允许的用户 patch 行,"写到了 bundle insert 行"的假设不成立;
|
|
81
|
+
实测复现(2026-11-04 浏览器点击开关 + `cordis.patch.yml` mtime 前后比对)显示拒绝发生在宿主
|
|
82
|
+
`edit()` **落盘之前**(文件未被触碰、快速返回 settings/rejected),确切 throw 行仍需宿主侧
|
|
83
|
+
stderr(tty,本环境不可读)。本轮插件侧改动:写入被拒时经插件自有的 `remote.settings.mutate`
|
|
84
|
+
(同一宿主写路径)做**一次无条件 revision 回退写**(expectedRevision 省略 = 宿主文档化的
|
|
85
|
+
无条件写),陈旧 revision 冲突可就地自愈;仍被拒时卡片以
|
|
86
|
+
「保存未生效,宿主拒绝原因:{message}」透出宿主 RemoteError 原文(ConfigFormController 原本把
|
|
87
|
+
错误坍缩成裸 `false`)。离线测试增至 54 项,新增写入落点断言(回退写无 revision、主写成功不触
|
|
88
|
+
回退、无 remote 降级不崩)。
|
|
89
|
+
v0.7.7:**agent 模型字段 volatile 化(表单可写性根治)**——本轮实机复现(2026-11-04 浏览器点击
|
|
90
|
+
插件详情页开关):写仍被宿主拒绝(快照回弹「全目标模式:关」,profile `cordis.patch.yml` mtime
|
|
91
|
+
21:58:35 未变,拒绝发生在落盘之前),且当前宿主进程 2026-11-03 22:04 启动、早于 v0.7.6 提交
|
|
92
|
+
(00:40),运行中的仍是旧客户端模块。新增宿主侧取证:`@deepseek-ai/dsh-settings` 的
|
|
93
|
+
`volatileForm(schema)`(lib/index.js:122-131)只保留 `.volatile()` 字段作为整个设置表单,
|
|
94
|
+
`write()`(同文件 502-531 行)对每个写入路径做 `isVolatilePath` 检查并抛
|
|
95
|
+
「Config field "…" is not volatile」——即宿主表单**只服务也只接受** volatile 字段;据此把
|
|
96
|
+
taskAgentModel / verifyAgentModel 两个扁平字段也改为 `.volatile()`(此前非 volatile =
|
|
97
|
+
在插件页既不可见也不可写),读取点改为 `readConfigField(config.taskAgentModel, '')` 解包
|
|
98
|
+
活引用('' = 与 session agent 一致),语义保持「保存后下一轮 goal 即生效,无需重载」。
|
|
99
|
+
离线测试 55 项全过(测试 55 改为断言两字段 volatile + 活引用解析 + readConfigField 解包布线)。
|
|
100
|
+
生效步骤:重启宿主(客户端模块图 + 服务端 schema 都在启动时装配)+ 强刷浏览器;验证步骤:
|
|
101
|
+
插件详情页拨「全目标模式」→ 开关保持 + 摘要行变「全目标模式:开」+ profile patch 出现
|
|
102
|
+
`dsh-cybernetics` 用户行;仍失败时卡片将显示宿主拒绝原文(v0.7.6 面)。
|
|
103
|
+
v0.7.8:**写入落点证明测试(测试 56)**——把宿主 `@deepseek-ai/dsh-settings` write() 的全部
|
|
104
|
+
预写闸门(volatileForm / isVolatilePath / projectForm / mutate op reduce / validatePaths /
|
|
105
|
+
strip + mergeLayers,lib/index.js 122-179 与 488-534 行)逐行复刻,对**真实导出的 Config
|
|
106
|
+
schema** 重放卡片的 toggle 写:开关写通过每一道闸门、经 schemastery 校验
|
|
107
|
+
(= cordis `resolveConfig` 的精确检查)后产出持久化 config,最终落点形状恰为
|
|
108
|
+
`ConfigEditor.edit`(dsh-config-editor lib/index.js 150-155 行)追加到 profile
|
|
109
|
+
`cordis.patch.yml` 的**用户 patch 行** `{id, name, config}`(无 `insert` 键)——即
|
|
110
|
+
`reconcileProfilePatches`(dsh-app-boot lib/index.js:3794)唯一接受的落点。
|
|
111
|
+
负向对照:`hardGate` 等非 volatile 路径在写前即被同一闸门以
|
|
112
|
+
「Config field "hardGate" is not volatile」拒绝、文件不被触碰——这正是 v0.7.4/v0.7.5
|
|
113
|
+
实机观测到的拒绝签名(profile patch mtime 不变、快速 rejected):**运行中宿主装配的
|
|
114
|
+
插件 schema 若早于 volatile 标注,宿主即在写前拒绝**;生效手段不变:重启宿主装配
|
|
115
|
+
v0.7.7 schema + 强刷浏览器。离线测试增至 56 项。
|
|
23
116
|
v0.6:多 goal session 投影(`cyberneticsGoals`)——goal 板每次变化把本 session 全部 goal 的
|
|
24
117
|
整值快照 append 成一条 `plugin:cybernetics/goals` 事件(可跳过的插件记录,宿主持久化读取安全),
|
|
25
118
|
服务端纯折叠、宿主整值推送,「目标」页签逐条渲染并就地更新;空板回退到 dsh-goal 单 goal 渲染。
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Issue 草稿 / Issue Draft
|
|
2
|
+
|
|
3
|
+
> 本文件为可直接提交至上游仓库 DeepSeek-AI/deepseek-harness 的 issue 草稿。
|
|
4
|
+
> Title(英文)见下;正文以中文为主,附英文摘要。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Title / 标题
|
|
9
|
+
|
|
10
|
+
**[EN]** Bug: Persistent SSE/long-polling event streams from client plugins exhaust the browser's per-host HTTP/1.1 connection pool (~6 connections), leaving new API requests (e.g. `/api/pluginManager/listBundles`) queued indefinitely and the page unresponsive
|
|
11
|
+
|
|
12
|
+
**[ZH]** 各插件常驻 SSE/长轮询事件流占满浏览器对同一 host 的 HTTP/1.1 连接池(约 6 条),导致新请求(如 `/api/pluginManager/listBundles`)无限排队、页面假死
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Summary / 摘要(EN)
|
|
17
|
+
|
|
18
|
+
When multiple DSH web tabs are open against the same host, the browser's per-origin HTTP/1.1 connection limit (~6 sockets in Chrome) is saturated by resident SSE / long-polling event streams opened by client plugins (plus dsh-farm's 5s polling). New requests — e.g. `POST /api/pluginManager/listBundles`, `listPlugins`, `listVersionExemptions`, `schedule/list`, `GET /farm/services` — stay *pending* for 15s+ and end with `net::ERR_ABORTED`, while requests from already-established tabs still return 200. The page appears frozen. Server-side investigation rules out a slow host: `listBundles` is fully synchronous and millisecond-scale, and the host main thread is idle (94% in `kevent`).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 正文 / Body(ZH)
|
|
23
|
+
|
|
24
|
+
### Environment / 环境
|
|
25
|
+
|
|
26
|
+
- dsh `0.2.1-rc.3`
|
|
27
|
+
- OS: macOS
|
|
28
|
+
- Browser: Chrome(HTTP/1.1;宿主 server 当前仅支持 HTTP/1.1,未启用 h2)
|
|
29
|
+
- profile 挂载了大量客户端插件(含多个带常驻 SSE/长轮询事件流的插件,以及 dsh-farm 服务)
|
|
30
|
+
- 同一 host 下多标签页使用
|
|
31
|
+
|
|
32
|
+
### Steps to Reproduce / 复现步骤
|
|
33
|
+
|
|
34
|
+
1. 启动 DSH web profile,浏览器打开 DSH web 页面(标签页 A),登录。
|
|
35
|
+
2. 再开 1–2 个同 host 的 DSH web 标签页(标签页 B、C),保持打开(各标签页的插件事件流与轮询即开始占用连接)。
|
|
36
|
+
3. 在新标签页中打开插件页(如 plugin manager UI)。
|
|
37
|
+
4. 打开 DevTools → Network 面板,观察:
|
|
38
|
+
- `POST /api/pluginManager/listBundles`
|
|
39
|
+
- `POST /api/pluginManager/listPlugins`
|
|
40
|
+
- `POST /api/pluginManager/listVersionExemptions`
|
|
41
|
+
- `POST /api/schedule/list`
|
|
42
|
+
- `GET /farm/services`
|
|
43
|
+
|
|
44
|
+
### Expected vs Actual / 预期与实际
|
|
45
|
+
|
|
46
|
+
- **Expected**:上述 API 在毫秒~秒级返回 200,页面正常渲染、可交互。
|
|
47
|
+
- **Actual**:新标签页中上述请求全部 **pending 15s+** 后以 `net::ERR_ABORTED` 失败;与此同时老标签页对同一批 API 的请求仍正常返回 **200**。页面交互假死(点击、渲染均无响应)。独立复现 3 次现象一致。
|
|
48
|
+
|
|
49
|
+
### Analysis / 根因分析(附证据)
|
|
50
|
+
|
|
51
|
+
**已排除服务端问题:**
|
|
52
|
+
|
|
53
|
+
1. 宿主 `listBundles` 实现为全同步逻辑,单次调用仅毫秒级 —— 证据:`dsh-plugin-manager/lib/index.js:1681-1799`。
|
|
54
|
+
2. macOS `sample` 抓栈显示宿主进程主线程空闲,94% 时间停留在 `kevent`(事件循环等待),不存在主线程阻塞。
|
|
55
|
+
3. 未登录状态下直接 curl 该接口,**1ms 返回 401** —— 请求根本没被服务端排队,排除网关/路由层阻塞。
|
|
56
|
+
|
|
57
|
+
**真实机制:**
|
|
58
|
+
|
|
59
|
+
- 宿主 server 仅支持 HTTP/1.1(无 h2 多路复用),Chrome 对同一 host 默认仅约 6 条并发 TCP 连接。
|
|
60
|
+
- 每个标签页除 1 条 `/api/remote.mux` WebSocket(WS 不占用 HTTP 连接池)外,各客户端插件还各自打开常驻 **SSE / 长轮询事件流**;dsh-farm 另有**每 5 秒一次**的轮询请求。这些常驻流把 6 条连接持续占满。
|
|
61
|
+
- 于是新标签页发起的普通 API 请求只能在浏览器 socket 池中**无限排队**,直到超时被浏览器 abort(15s+ 后 `net::ERR_ABORTED`),表现为页面假死。这也一并解释了「多标签页导致浏览器失响应」的现象。
|
|
62
|
+
|
|
63
|
+
### Suggested Fixes / 修复建议(分层)
|
|
64
|
+
|
|
65
|
+
**宿主 / 上游侧:**
|
|
66
|
+
|
|
67
|
+
1. **事件通道合并**:将各插件独立的 SSE/长轮询事件流迁移到已存在的 `/api/remote.mux` WebSocket 上复用单一条连接(每标签页一条 WS,多路复用所有插件事件),从根本上消除连接池占用。
|
|
68
|
+
2. **client RPC 加固**:client RPC 增加请求超时与失败重试,避免排队超时后页面无限等待、无恢复路径。
|
|
69
|
+
3. **dsh-farm 轮询降频**:dsh-farm 的 5 秒轮询改为指数退避,或迁移为 WS 推送。
|
|
70
|
+
4. **评估启用 HTTP/2**:宿主 server 评估支持 h2,使浏览器对同一 host 多路复用,绕开 HTTP/1.1 的 6 连接限制。
|
|
71
|
+
|
|
72
|
+
**用户侧临时缓解:**
|
|
73
|
+
|
|
74
|
+
- 减少同一 host 下打开的 DSH web 标签页数量;
|
|
75
|
+
- 停用带常驻 SSE/长轮询事件流的插件。
|
|
76
|
+
|
|
77
|
+
### Additional Context / 补充
|
|
78
|
+
|
|
79
|
+
以上结论来自对 `0.2.1-rc.3` 的排查:服务端同步实现(`dsh-plugin-manager/lib/index.js:1681-1799`)+ `sample` 主线程空闲证据 + 401 快速返回,三点共同将问题定位在浏览器连接池耗尽,而非宿主处理能力。如需完整排查记录可进一步提供。
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# /api/pluginManager/listBundles 挂起问题排查报告(cybernetics 本轮,只读调研)
|
|
2
|
+
|
|
3
|
+
日期:2026-11-03(本地 23:39–00:02)
|
|
4
|
+
执行者:task agent(dsh-cybernetics 会话,宿主 checkout 只读)
|
|
5
|
+
|
|
6
|
+
## 结论(一句话)
|
|
7
|
+
|
|
8
|
+
`/api/pluginManager/listBundles` 挂起**不是** listBundles 实现慢或死锁,也**不是**插件页重复调用导致宿主扫描过重;
|
|
9
|
+
它是**浏览器对 HTTP/1.1 同一 host 的连接池(约 6 条)被大量常驻长连接(WS 之外的 SSE/事件流 + 轮询)占满后,
|
|
10
|
+
新请求在浏览器侧排队、永远等不到 socket** 的现象。宿主进程主线程空闲、从未收到/处理这些请求。
|
|
11
|
+
多标签页打开 GUI 时每个标签都会新增一条 `/api/remote.mux` WebSocket + 多条事件流,迅速耗尽 6 条池连接——
|
|
12
|
+
这正是"多标签页导致浏览器失去响应"和"插件页卡片链路(listBundles/listPlugins)被阻塞"的共同根源。
|
|
13
|
+
|
|
14
|
+
## 复现(真实环境,证据)
|
|
15
|
+
|
|
16
|
+
- 三次独立复现(三个全新浏览器会话,23:41 / 23:48 / 23:55):带登录态 `POST /api/pluginManager/listBundles`
|
|
17
|
+
7.6s/15.6s/8.3s 超时,`net::ERR_ABORTED`,**从未收到响应头**(CDP 网络捕获:request state=failed/pending,
|
|
18
|
+
response_body unavailable)。同法 `pluginManager/listPlugins`、`listVersionExemptions`、`schedule/list` 同样挂起。
|
|
19
|
+
- 未登录 `curl -X POST http://127.0.0.1:3080/api/...` → **401,1ms 内返回**(说明 HTTP 服务、路由、admit 全部正常)。
|
|
20
|
+
- 宿主进程 `sample 39225`(端口 3080 监听者):主线程 2260 个采样中 2121 个停在 `kevent`(事件循环空闲等待),
|
|
21
|
+
**没有阻塞**;常驻内存 1.1GB(另一个值得关注的独立问题)。
|
|
22
|
+
- 用户实际打开插件页(/plugins/??…)时,整页请求全部 pending:`/farm/services`(×6,5 秒重试堆积)、
|
|
23
|
+
`/api/pluginManager/listPlugins`、`/api/pluginManager/listBundles`、`/api/schedule/list`、`/api/commands/list`、
|
|
24
|
+
`/api/skills/list`、`/api/credentials/describe`(×3)、`/api/pet/*`、`/api/skin-center/*`、
|
|
25
|
+
`/api/dsh-usage/overview`(20 秒后被页面自身超时中止)——见 CDP capture `listBundles-hang`(116 条请求,~25 条 pending)。
|
|
26
|
+
- `lsof -nP -iTCP:3080`:Chrome 持有 **17 条** ESTABLISHED 连接;`curl -I` 确认服务器为 **HTTP/1.1**(无 h2)。
|
|
27
|
+
Chromium 对 HTTP/1.1 同一 host 的并发连接上限为 6;WS 不占该池,SSE/普通 fetch 占用。
|
|
28
|
+
|
|
29
|
+
## 代码证据(文件+行,均为只读引用)
|
|
30
|
+
|
|
31
|
+
1. listBundles 本身是**全同步**方法,不可能自行挂起:
|
|
32
|
+
- `@deepseek-ai/dsh-plugin-manager/lib/index.js:1681`(`listBundles()`,profile 与全局安装 diff 为 SAME)
|
|
33
|
+
- 实现内只有 `readFileSync`/YAML 解析(`lib/index.js:1766-1799` 收尾 `return Promise.resolve(bundles)`);
|
|
34
|
+
实测量级:80 个依赖的 package.json 共 223KB + 57 个 patch 文件 15KB,单次调用远小于 100ms。
|
|
35
|
+
2. 分发链路也没有异步等待点(profile 与全局 SAME):
|
|
36
|
+
- `@deepseek-ai/dsh-client-connection/lib/index.js:1045-1062`:`/api` 路由 admit(401 快路径)→ `bridge` →
|
|
37
|
+
`rpcFetchHandler.fetch`(:871-913)→ gateway `dispatchRpc`。
|
|
38
|
+
- `@deepseek-ai/dsh-api-gateway/lib/index.js:961-978`(dispatchRpc/invokeRpc)、`:1190-1230`(prepareInvocation,
|
|
39
|
+
strict 描述符 `invocation:{kind:'direct'}`,见 `dsh-plugin-manager/lib/typert.host.js:580-604`)→ 同步调用业务方法。
|
|
40
|
+
3. 浏览器侧传输(挂起真正发生的地方):
|
|
41
|
+
- `@deepseek-ai/dsh-client-connection/lib/client.browser.js:1209-1237`(`createWebConnectionRpc.call`):
|
|
42
|
+
普通 `fetch(POST /api/<endpoint>)`,**无超时**;请求发不出去(池排队)时 promise 永远 pending。
|
|
43
|
+
- WS 通道 `/api/remote.mux` 定义于 `dsh-api-gateway/lib/index.js:133`;浏览器每页一条
|
|
44
|
+
(`dsh-api-gateway/lib/client.browser.js:1630`),WS 不占 HTTP 池,但每个标签的 SSE/事件流占用。
|
|
45
|
+
4. 放大器(插件侧):
|
|
46
|
+
- `dsh-farm/client.js:209`:`setInterval(refresh, 5000)` —— 每个挂载了 farm 面板的标签每 5 秒打一次
|
|
47
|
+
`/farm/services`;请求排队后,每个 tick 再堆一条 pending(捕获中同一 tab 堆了 6 条)。
|
|
48
|
+
- 插件页自身的读组逻辑 `@linxin666/dsh-client-ui-plugin-manager/lib/client.js:635`(readGroup 并发调
|
|
49
|
+
listPlugins + listBundles)本身频率合理,不是问题源头。
|
|
50
|
+
|
|
51
|
+
## 与委托书预期的偏差
|
|
52
|
+
|
|
53
|
+
- 委托书假设"listBundles 读多少文件/扫多少目录/等待锁导致挂起"——已排除:实现同步、量级小、
|
|
54
|
+
宿主主线程空闲、且同时段 `/farm/services` 等非 RPC 路由同样挂起。
|
|
55
|
+
- 委托书假设"SSE 资源耗尽"——**方向正确**:耗尽的是浏览器每 host 6 条的 HTTP/1.1 连接池,
|
|
56
|
+
被 GUI 各标签的常驻事件流(以及每标签 1 条 WS 之外的 SSE 类流)占满。
|
|
57
|
+
- 与并行 goal cyb-30cba954 的关系:其结论"listBundles 挂起阻塞插件页"成立,但归因(宿主实现)
|
|
58
|
+
需修正为浏览器连接池饥饿;其改动(cordis.patch.yml / lib/client.js,dsh-cybernetics 仓库)不受影响。
|
|
59
|
+
|
|
60
|
+
## 修复建议分层
|
|
61
|
+
|
|
62
|
+
### A. 用户侧(立即生效,无需改码)
|
|
63
|
+
- 关闭多余的 GUI 标签页(尤其是停留在插件页/看板页的后台标签),挂起的 listBundles 立即恢复返回。
|
|
64
|
+
|
|
65
|
+
### B. 插件侧(可落地,但不在本轮可写仓库内)
|
|
66
|
+
本轮可写仓库为 `/Users/mars/jobs/dsh-cybernetics`(dsh-cybernetics),其 client 不发起任何常驻 HTTP 流
|
|
67
|
+
(`lib/client.js` 只使用宿主提供的 faces),**无该问题的可落地修复点** —— 按验收项 2 如实报告"本仓库无可行插件侧方案"。
|
|
68
|
+
真正值得改的插件(均在只读 profile 内,属下一轮或对应仓库任务):
|
|
69
|
+
- `dsh-farm/client.js:209`:把固定 5s 轮询改为事件驱动/指数退避 + `document.visibilityState==='hidden'` 时暂停;
|
|
70
|
+
- 各自维护 SSE/长轮询的插件(taskboard events、convmap、pet 等)合并为共享一条流或迁移到 `/api/remote.mux` WS 复用;
|
|
71
|
+
- `@linxin666/dsh-client-ui-plugin-manager`:对 readGroup 结果做 30-60s 内存缓存,失败重试加退避,
|
|
72
|
+
避免排队期间反复堆叠请求。
|
|
73
|
+
|
|
74
|
+
### C. 宿主/上游侧(建议补丁位置,未修改)
|
|
75
|
+
- 核心:减少长连接对浏览器 6-socket 池的占用。`@deepseek-ai/dsh-api-gateway/lib/index.js:133`
|
|
76
|
+
已有 WS mux(`/api/remote.mux`)承载 Typert 流;建议上游把插件自定义事件通道统一迁移到该 mux
|
|
77
|
+
的逻辑流(open/item/end/cancel 帧协议已在 `stream-protocol` 实现),或提供官方 SSE→WS 复用桥。
|
|
78
|
+
- 次要:`@deepseek-ai/dsh-client-connection/lib/client.browser.js:1209` 的 `createWebConnectionRpc.call`
|
|
79
|
+
增加可配置超时(如 AbortSignal.timeout(30s)),把"永远 pending"变成可见错误;
|
|
80
|
+
`dsh-host-webserver` 可考虑响应头提示或文档说明 HTTP/1.1 单 host 限制。
|
|
81
|
+
- 观察项:宿主进程 RSS 1.1GB(sample 输出 Physical footprint),建议上游加内存基线告警(独立问题)。
|
|
82
|
+
|
|
83
|
+
## 验证方式(可复现命令)
|
|
84
|
+
1. `curl -o /dev/null -w '%{http_code} %{time_total}\n' -X POST -H 'content-type: application/json' -d '{}' http://127.0.0.1:3080/api/pluginManager/listBundles` → `401 0.001`(服务正常)。
|
|
85
|
+
2. `sample <node-pid> 3` → 主线程 kevent 空闲(服务未阻塞)。
|
|
86
|
+
3. 登录态浏览器 `fetch('/api/pluginManager/listBundles', {method:'POST', ...})` + AbortController(8s) → 挂起;
|
|
87
|
+
同时 `lsof -nP -iTCP:3080` 数 Chrome ESTABLISHED ≈17(HTTP/1.1,池 6)。
|
|
88
|
+
4. CDP 网络捕获:新开插件页后 `/api/pluginManager/listBundles`、`/api/schedule/list`、`/farm/services` 全部 pending。
|
|
89
|
+
|
|
90
|
+
## 风险与残余不确定
|
|
91
|
+
- "池排队"是从浏览器外部可观测行为(请求未送达 + 服务空闲 + 旧连接正常 + HTTP/1.1 + 连接数)反推的最优解释;
|
|
92
|
+
如需 100% 确证,可用 chrome://net-export 抓一次 net-log(需要用户配合,本轮未做)。
|
|
93
|
+
- 未修改任何文件;未运行测试(纯只读调研轮,无代码变更故无测试项)。
|
package/docs/protocol.md
CHANGED
|
@@ -1,7 +1,41 @@
|
|
|
1
|
-
# Cybernetics 工作协议 v0.
|
|
1
|
+
# Cybernetics 工作协议 v0.7(设计稿)
|
|
2
2
|
|
|
3
3
|
目标-行动-测量-校准的迭代回路,构建在 DSH 的 `dsh-workflow` 编排与 `dsh-goal` 持久目标之上。
|
|
4
4
|
|
|
5
|
+
## v0.7 已实现:全量 goal 模式(allGoalsMode)
|
|
6
|
+
|
|
7
|
+
配置项 `allGoalsMode`(默认 **false**——关闭时注入的提示段、revise 行为与 v0.6 完全一致)。
|
|
8
|
+
开启后在隐式触发段后追加「ALL-GOALS MODE ACTIVE」路由协议,四条路由规则:
|
|
9
|
+
|
|
10
|
+
> 配置入口(v0.7.1):插件导出 schemastery `Config` schema,`allGoalsMode` 为 volatile 字段——
|
|
11
|
+
> 在 DSH Web 编辑器 Settings → Plugins 的本插件条目面板上直接切换,保存后 loader 把新值
|
|
12
|
+
> 提交进运行实例(不 remount),提示段即时重挂。无需再手改 profile JSON。
|
|
13
|
+
|
|
14
|
+
1. **全量进 goal**:任何用户请求(问题、检索、分析、改动)一律先建 goal(action `'start'` +
|
|
15
|
+
title/brief)再由 task agent 执行;session agent 禁止用读工具就地完成任务——哪怕单文件
|
|
16
|
+
查询或一次计算也走回路。硬门关闭时协议同样禁止就地改动:必须委派。
|
|
17
|
+
2. **简单只读请求 → depth `'quick'`**:单 task agent 一轮,跳过 verify/triage,结果直接反馈
|
|
18
|
+
(有界查询、单文件读取、小型统计)。
|
|
19
|
+
3. **复杂分析 / 写任务 → depth `'full'` 多轮渐进**:action `'start'` 异步启动,逐轮 `'collect'`
|
|
20
|
+
收敛,controller 按轮推进 subgoal(task → verify → triage)。分析型 = 取数落盘、数据集、
|
|
21
|
+
统计、多步查询规划;写任务额外依赖硬门——session agent 永不亲自执行改动,goal 回路是
|
|
22
|
+
唯一路径,改动再小也一样。
|
|
23
|
+
4. **revise 自动决策树**:修订仍是 append-only(新 goal + `parentGoalId`,旧 goal 置
|
|
24
|
+
`superseded`,`'revise'` 是唯一目标变更动词),但 `abortPrevious` 缺省时按旧 task agent
|
|
25
|
+
状态自动决策(数据来自现有 goal 板 running 状态,不新增编排机制):
|
|
26
|
+
- (a) 旧轮**在跑**且修订改写目标本身(传入 `objective`,或 `depth` 变化)→ 目标矛盾/
|
|
27
|
+
写域冲突 → **自动中止旧轮**(复用 cancel 路径),等价 `abortPrevious: true`;
|
|
28
|
+
- (b) 旧轮**在跑**但目标不变(仅 brief/title/role 修正),旧工作仍可复用/可并行 →
|
|
29
|
+
**等待旧轮自然结束**,等价 `abortPrevious: false`;
|
|
30
|
+
- (c) 旧 task agent **已结束**(goal 板上该 goal 非 running)→ **直接启动新 goal**,
|
|
31
|
+
不做任何中止动作。
|
|
32
|
+
显式传 `abortPrevious: true/false` 保持 v0.6 人工控制语义(决策树让位)。每次修订的
|
|
33
|
+
`.cybernetics/revisions-<新goalId>.jsonl` 审计行新增 `abort` 字段
|
|
34
|
+
`{abort, mode: 'auto'|'explicit', basis}`,记录选择的分支与依据;`revise` 返回值同样携带。
|
|
35
|
+
|
|
36
|
+
实现落点:`src/controller.ts` 的纯函数 `decideAbort`(决策树 + basis 文案);
|
|
37
|
+
`src/index.ts` 的 `ALL_GOALS_GUIDANCE` 提示段与 revise 路径接线。`src/` 改动需宿主重载生效。
|
|
38
|
+
|
|
5
39
|
## v0.6 设计:与 Agent Teams 集成
|
|
6
40
|
|
|
7
41
|
2026-11-02 与用户逐条定稿的设计决策。第 0 步验证实验已完成(见文末);执行者适配层
|
package/lib/client.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// dsh-cybernetics client module: the「目标 / Goal」conversation tab
|
|
1
|
+
// dsh-cybernetics client module: the「目标 / Goal」conversation tab, the
|
|
2
|
+
//「控制」tab, and (v0.7.2+, fixed in v0.7.3) the allGoalsMode settings card
|
|
3
|
+
// seated on the official Plugins page (plugins.item / plugins.row.config /
|
|
4
|
+
// plugins.bundle.config, bound to the row's ConfigForm in either of the two
|
|
5
|
+
// shapes the seats deliver — volatile writes stay live without a restart).
|
|
2
6
|
// Rendered from the session's `goal` projection (dsh-goal), pushed live by the
|
|
3
7
|
// host — no plugin HTTP route needed. Hand-written ModuleLoader wrapper, same
|
|
4
8
|
// format the host bundler emits (see dsh-context / dsh-maze client.js).
|
|
@@ -62,6 +66,22 @@ window.__ModuleLoader__.load({
|
|
|
62
66
|
// v0.6 executor line: the durable teammate's name (persistent only)
|
|
63
67
|
'goal.member': '成员 {name}',
|
|
64
68
|
'control.goals': '目标',
|
|
69
|
+
// v0.7.2 plugins-page settings card (plugins.item / plugins.row.config /
|
|
70
|
+
// plugins.bundle.config). cardLabel titles the 内置插件配置 group card.
|
|
71
|
+
'settings.cardLabel': 'Cybernetics 编排循环',
|
|
72
|
+
'settings.title': '全目标模式',
|
|
73
|
+
'settings.description': '所有用户请求(含只读问题)必须先经 cybernetics goal。保存后即时生效,无需重载。',
|
|
74
|
+
'settings.on': '开',
|
|
75
|
+
'settings.off': '关',
|
|
76
|
+
'settings.summaryOn': '全目标模式:开',
|
|
77
|
+
'settings.summaryOff': '全目标模式:关',
|
|
78
|
+
'settings.unavailable': '配置不可用(设置文档未开放或插件未运行)。',
|
|
79
|
+
'settings.saving': '保存中…',
|
|
80
|
+
'settings.saveFailed': '保存未生效(宿主拒绝了写入)。',
|
|
81
|
+
// v0.7.6 path-(b) diagnostics: the direct settings-transport retry
|
|
82
|
+
// surfaces the host's exact RemoteError message (the shared
|
|
83
|
+
// ConfigFormController swallows it into a bare `false`).
|
|
84
|
+
'settings.saveFailedDetail': '保存未生效,宿主拒绝原因:{message}',
|
|
65
85
|
};
|
|
66
86
|
const en = {
|
|
67
87
|
'view.goal': 'Goal',
|
|
@@ -102,6 +122,17 @@ window.__ModuleLoader__.load({
|
|
|
102
122
|
'goal.revisedFrom': 'Revised from "{title}"',
|
|
103
123
|
'goal.member': 'Member {name}',
|
|
104
124
|
'control.goals': 'Goals',
|
|
125
|
+
'settings.cardLabel': 'Cybernetics goal loop',
|
|
126
|
+
'settings.title': 'All-goals mode',
|
|
127
|
+
'settings.description': 'Every user request (including read-only questions) must go through a cybernetics goal first. Takes effect immediately on save; no reload needed.',
|
|
128
|
+
'settings.on': 'On',
|
|
129
|
+
'settings.off': 'Off',
|
|
130
|
+
'settings.summaryOn': 'All-goals mode: on',
|
|
131
|
+
'settings.summaryOff': 'All-goals mode: off',
|
|
132
|
+
'settings.unavailable': 'Configuration unavailable (settings document not served or plugin not running).',
|
|
133
|
+
'settings.saving': 'Saving…',
|
|
134
|
+
'settings.saveFailed': 'Save did not take effect (the host refused the write).',
|
|
135
|
+
'settings.saveFailedDetail': 'Save did not take effect — host refusal: {message}',
|
|
105
136
|
};
|
|
106
137
|
//#endregion
|
|
107
138
|
|
|
@@ -394,14 +425,267 @@ window.__ModuleLoader__.load({
|
|
|
394
425
|
}
|
|
395
426
|
//#endregion
|
|
396
427
|
|
|
428
|
+
//#region plugins-page settings card (v0.7.2)
|
|
429
|
+
// The official Plugins page (@deepseek-ai/dsh-client-ui-plugin-manager, the
|
|
430
|
+
// runtime copy that owns the 内置插件配置/实验性插件/已安装插件 groups) renders a
|
|
431
|
+
// bundle's own configuration ONLY through the slots it declares:
|
|
432
|
+
// plugins.bundle.config keyed by the bundle name (profile dep name)
|
|
433
|
+
// plugins.row.config keyed by `<bundle name>#<row id>`
|
|
434
|
+
// (rowConfigKey in that page's client.js; slot contract in
|
|
435
|
+
// dsh-cordis-client-runner's slot catalog). A server-side Config schema
|
|
436
|
+
// alone surfaces nowhere once the ui-settings-plugins section is disabled
|
|
437
|
+
// by the family plugin-manager — exactly the observed gap.
|
|
438
|
+
// Both entries bind the row's own ConfigForm from ctx.configForms (the
|
|
439
|
+
// namespace id is the loader row id `dsh-cybernetics`, same convention
|
|
440
|
+
// @deepseek-ai/dsh-experimental-client-ui-cot-translation uses). A toggle
|
|
441
|
+
// write goes settings mutate → loader volatile commit
|
|
442
|
+
// (`loader/volatile-update`), so allGoalsMode stays live without a
|
|
443
|
+
// restart — the v0.7.1 volatile semantics, unchanged.
|
|
444
|
+
const SETTINGS_ENTRY_ID = 'dsh-cybernetics';
|
|
445
|
+
const NO_FORM_SNAP = { status: 'unavailable', value: undefined, writable: false };
|
|
446
|
+
const noopSubscribe = () => () => {};
|
|
447
|
+
|
|
448
|
+
var settingsStyles = {
|
|
449
|
+
card: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' },
|
|
450
|
+
row: { display: 'flex', alignItems: 'center', gap: 12 },
|
|
451
|
+
main: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 },
|
|
452
|
+
title: { fontSize: 13, fontWeight: 600 },
|
|
453
|
+
desc: { fontSize: 12, opacity: 0.65, lineHeight: 1.5 },
|
|
454
|
+
toggle: {
|
|
455
|
+
cursor: 'pointer', borderRadius: 999, width: 40, height: 22,
|
|
456
|
+
border: '1px solid rgba(127,127,127,0.45)', position: 'relative', flex: 'none',
|
|
457
|
+
background: 'transparent', padding: 0,
|
|
458
|
+
},
|
|
459
|
+
knob: {
|
|
460
|
+
position: 'absolute', top: 2, width: 16, height: 16, borderRadius: 999,
|
|
461
|
+
transition: 'left .15s ease',
|
|
462
|
+
},
|
|
463
|
+
trackOn: { background: '#2da44e', borderColor: '#2da44e' },
|
|
464
|
+
trackOff: { background: 'rgba(127,127,127,0.25)' },
|
|
465
|
+
knobOn: { left: 20, background: '#fff' },
|
|
466
|
+
knobOff: { left: 2, background: 'currentColor' },
|
|
467
|
+
unavailable: { fontSize: 12, opacity: 0.6 },
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* One boolean settings row; shared by all three seats. Two form shapes
|
|
472
|
+
* reach it, and the card must speak both:
|
|
473
|
+
* - the LIVE ConfigForm this module injects (ctx.configForms.get):
|
|
474
|
+
* getSnapshot / subscribe / set — the only shape on the
|
|
475
|
+
* plugins.bundle.config page and in summary rows, whose owners pass
|
|
476
|
+
* no form of their own;
|
|
477
|
+
* - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
|
|
478
|
+
* plugins.row.config detail pages compute it from the same shared
|
|
479
|
+
* mirror (configForms.get(id).getSnapshot()/mutate) and pass it as
|
|
480
|
+
* an OWNER prop, which spreads AFTER inject at the render seam
|
|
481
|
+
* (@deepseek-ai/dsh-client-ui-renderer renderEntry: ...injected,
|
|
482
|
+
* ...ownerProps) — on those pages this shape WINS over the injected
|
|
483
|
+
* form. Reading `.subscribe` on it crashed the first v0.7.2 cut.
|
|
484
|
+
* The host shape carries no subscribe; its `state` is re-computed by
|
|
485
|
+
* the owning page on every mirror update, so reading it per render is
|
|
486
|
+
* already live.
|
|
487
|
+
*/
|
|
488
|
+
function SettingsCard(props) {
|
|
489
|
+
const t = props.t;
|
|
490
|
+
const form = props.form;
|
|
491
|
+
// v0.7.6 path-(b) fallback write face: the plugin's own bound
|
|
492
|
+
// remote.settings.mutate. SAME host write path as the ConfigForm
|
|
493
|
+
// (remote.settings.mutate → @deepseek-ai/dsh-settings SettingsForms
|
|
494
|
+
// → @deepseek-ai/dsh-config-editor edit → the USER patch row
|
|
495
|
+
// `{id, name, config}` appended to the profile cordis.patch.yml,
|
|
496
|
+
// reconciled by reconcileProfilePatches) — the only落点 the host
|
|
497
|
+
// accepts. Used ONLY when the primary write refuses, without an
|
|
498
|
+
// expectedRevision (the controller documents undefined as an
|
|
499
|
+
// unconditional write), so a stale-revision refusal heals in place
|
|
500
|
+
// and a hard refusal surfaces its exact host message instead of a
|
|
501
|
+
// bare「宿主拒绝了写入」with no reason.
|
|
502
|
+
const directMutate = props.directMutate;
|
|
503
|
+
const view = props.view === 'summary' ? 'summary' : 'page';
|
|
504
|
+
const live = form !== null && typeof form === 'object'
|
|
505
|
+
&& typeof form.subscribe === 'function' && typeof form.getSnapshot === 'function' && typeof form.set === 'function'
|
|
506
|
+
? form : undefined;
|
|
507
|
+
const host = live === undefined && form !== null && typeof form === 'object'
|
|
508
|
+
&& typeof form.mutate === 'function' && form.state !== null && typeof form.state === 'object'
|
|
509
|
+
? form : undefined;
|
|
510
|
+
const liveSnap = react.useSyncExternalStore(
|
|
511
|
+
live ? (cb) => live.subscribe(cb) : noopSubscribe,
|
|
512
|
+
live ? () => live.getSnapshot() : () => NO_FORM_SNAP,
|
|
513
|
+
);
|
|
514
|
+
const snap = host !== undefined ? host.state : liveSnap;
|
|
515
|
+
const [saving, setSaving] = react.useState(false);
|
|
516
|
+
// `false` = no failure; `true` = failure without a host message;
|
|
517
|
+
// a string = the host's exact refusal (RemoteError.message).
|
|
518
|
+
const [failed, setFailed] = react.useState(false);
|
|
519
|
+
const on = snap.status === 'ready' && snap.value != null && snap.value.allGoalsMode === true;
|
|
520
|
+
const disabled = (live === undefined && host === undefined) || snap.status !== 'ready' || snap.writable === false || saving;
|
|
521
|
+
|
|
522
|
+
const toggle = () => {
|
|
523
|
+
if (disabled) return;
|
|
524
|
+
setSaving(true);
|
|
525
|
+
setFailed(false);
|
|
526
|
+
// Both write paths land in the same host mutation: ConfigForm.set
|
|
527
|
+
// is one-field sugar over ConfigForm.mutate, and the host's
|
|
528
|
+
// ConfigPageForm.mutate IS that form's mutate (the expected
|
|
529
|
+
// revision carried explicitly). The host commits the write into
|
|
530
|
+
// the running plugin instance (volatile) and the next snapshot /
|
|
531
|
+
// page render pushes the accepted value back here.
|
|
532
|
+
// Failure policy (v0.7.4): the transport can REFUSE (settles
|
|
533
|
+
// false) or stall (the observed "保存中" hang). Neither is
|
|
534
|
+
// silent: a refused write shows saveFailed for a few seconds,
|
|
535
|
+
// and a stalled write releases the switch after 15 s with the
|
|
536
|
+
// same hint — the switch always returns to the snapshot value,
|
|
537
|
+
// which is the only state the host confirmed.
|
|
538
|
+
// v0.7.6: a refused write is retried ONCE through the direct
|
|
539
|
+
// settings transport (props.directMutate, no expectedRevision).
|
|
540
|
+
// Same落点 (user patch row via configEditor), unconditional
|
|
541
|
+
// revision — a stale-revision conflict therefore heals, and any
|
|
542
|
+
// remaining refusal reports its host message verbatim.
|
|
543
|
+
let settled = false;
|
|
544
|
+
const finish = (ok, detail) => {
|
|
545
|
+
if (settled) return;
|
|
546
|
+
settled = true;
|
|
547
|
+
setSaving(false);
|
|
548
|
+
if (!ok) {
|
|
549
|
+
setFailed(detail || true);
|
|
550
|
+
setTimeout(() => setFailed(false), 6000);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const guard = setTimeout(() => finish(false), 15000);
|
|
554
|
+
const op = { op: 'set', path: ['allGoalsMode'], value: !on };
|
|
555
|
+
const write = live !== undefined
|
|
556
|
+
? live.set('allGoalsMode', !on)
|
|
557
|
+
: host.mutate([op], snap.revision);
|
|
558
|
+
Promise.resolve(write)
|
|
559
|
+
.then(async (accepted) => {
|
|
560
|
+
if (accepted !== false) { clearTimeout(guard); finish(true); return; }
|
|
561
|
+
if (typeof directMutate !== 'function') { clearTimeout(guard); finish(false); return; }
|
|
562
|
+
try {
|
|
563
|
+
const answer = await directMutate([op]);
|
|
564
|
+
clearTimeout(guard);
|
|
565
|
+
if (answer && typeof answer === 'object' && answer.ok === false) {
|
|
566
|
+
finish(false, answer.error && answer.error.message);
|
|
567
|
+
} else finish(true);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
clearTimeout(guard);
|
|
570
|
+
finish(false, error && error.message);
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
.catch((error) => { clearTimeout(guard); finish(false, error && error.message); });
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
if (view === 'summary') {
|
|
577
|
+
return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return jsx.jsxs('div', { style: settingsStyles.card, children: [
|
|
581
|
+
jsx.jsxs('div', { style: settingsStyles.row, children: [
|
|
582
|
+
jsx.jsxs('div', { style: settingsStyles.main, children: [
|
|
583
|
+
jsx.jsx('div', { style: settingsStyles.title, children: t('settings.title') }),
|
|
584
|
+
jsx.jsx('div', { style: settingsStyles.desc, children: t('settings.description') }),
|
|
585
|
+
] }),
|
|
586
|
+
snap.status === 'ready'
|
|
587
|
+
? jsx.jsx('button', {
|
|
588
|
+
type: 'button', role: 'switch', 'aria-checked': on, 'aria-label': t('settings.title'),
|
|
589
|
+
disabled, onClick: toggle,
|
|
590
|
+
style: { ...settingsStyles.toggle, ...(on ? settingsStyles.trackOn : settingsStyles.trackOff) },
|
|
591
|
+
children: jsx.jsx('span', { style: { ...settingsStyles.knob, ...(on ? settingsStyles.knobOn : settingsStyles.knobOff) } }),
|
|
592
|
+
})
|
|
593
|
+
: null,
|
|
594
|
+
] }),
|
|
595
|
+
snap.status !== 'ready' ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.unavailable') }) : null,
|
|
596
|
+
saving ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.saving') }) : null,
|
|
597
|
+
!saving && failed ? jsx.jsx('div', { style: { ...settingsStyles.unavailable, color: '#d29922' }, children: failed === true ? t('settings.saveFailed') : t('settings.saveFailedDetail', { message: failed }) }) : null,
|
|
598
|
+
] });
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
|
|
397
602
|
//#region plugin apply
|
|
398
603
|
const name = 'dsh-cybernetics';
|
|
399
|
-
|
|
604
|
+
// configForms: the per-entry ConfigForm registry (@deepseek-ai/dsh-client-ui-settings)
|
|
605
|
+
// that backs the plugins-page settings card below.
|
|
606
|
+
const inject = ['slots', 'sessions', 'locale', 'configForms',
|
|
607
|
+
// v0.7.6: the settings card's diagnostic fallback write rides the
|
|
608
|
+
// same namespace remote the official ui-settings declares
|
|
609
|
+
// (["remote", "remote.settings"]). Both are required: ctx access is
|
|
610
|
+
// gated by the inject declaration (client-runner guard), so an
|
|
611
|
+
// undeclared `remote` would throw on read and silently disable the
|
|
612
|
+
// fallback. Optional at runtime (guarded in apply): a host without
|
|
613
|
+
// the namespace parks this client module exactly as it would
|
|
614
|
+
// ui-settings itself — the web profile always serves it.
|
|
615
|
+
'remote', 'remote.settings'];
|
|
400
616
|
|
|
401
617
|
function apply(ctx) {
|
|
402
618
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-cybernetics: dictionaries');
|
|
403
619
|
const t = ctx.locale.bind(NS);
|
|
404
620
|
|
|
621
|
+
// v0.7.2: bind the row's ConfigForm once per apply body. The namespace
|
|
622
|
+
// is the loader row id (`dsh-cybernetics`), the same convention the
|
|
623
|
+
// official cot-translation bundle uses (`ctx.configForms.get(ns)`). If
|
|
624
|
+
// the host does not serve this namespace yet, the card renders its
|
|
625
|
+
// unavailable state instead of throwing.
|
|
626
|
+
let settingsForm;
|
|
627
|
+
try {
|
|
628
|
+
settingsForm = ctx.configForms.get(SETTINGS_ENTRY_ID);
|
|
629
|
+
} catch {
|
|
630
|
+
settingsForm = undefined;
|
|
631
|
+
}
|
|
632
|
+
// v0.7.6 path-(b) diagnostics face: the direct settings transport,
|
|
633
|
+
// bound from this module's own context (`remote.settings` inject).
|
|
634
|
+
// Guarded: a deployment without the namespace leaves it undefined
|
|
635
|
+
// and the card falls back to the v0.7.4 hint-only failure state.
|
|
636
|
+
let settingsDirectMutate;
|
|
637
|
+
try {
|
|
638
|
+
if (ctx.remote && ctx.remote.settings && typeof ctx.remote.settings.mutate === 'function') {
|
|
639
|
+
settingsDirectMutate = (ops, expectedRevision) => ctx.remote.settings.mutate(SETTINGS_ENTRY_ID, ops, expectedRevision);
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
settingsDirectMutate = undefined;
|
|
643
|
+
}
|
|
644
|
+
const settingsInject = () => ({ form: settingsForm, directMutate: settingsDirectMutate });
|
|
645
|
+
// v0.7.2 (the actual gap): the official Plugins page renders the
|
|
646
|
+
// 内置插件配置 group cards ONLY from the `plugins.item` slot
|
|
647
|
+
// (ItemCard in @deepseek-ai/dsh-client-ui-plugin-manager client.js:
|
|
648
|
+
// basicCards = ledger.items from slot "plugins.item"; ItemDetail
|
|
649
|
+
// renders view "page" with the host form for item.id). The keyed
|
|
650
|
+
// seats below surface only on an installed bundle's own page, which
|
|
651
|
+
// this linked bundle never reaches in the 已安装插件 group — that was
|
|
652
|
+
// why v0.7.1's schema alone (and the first v0.7.2 cut) showed no card.
|
|
653
|
+
// Register the list card UNCONDITIONALLY (v0.7.5 hardening): the earlier
|
|
654
|
+
// cut gated it on configForms.whileServed, so a deployment where the
|
|
655
|
+
// host does not (yet) serve the `dsh-cybernetics` namespace showed NO
|
|
656
|
+
// card at all — indistinguishable from the registration never running.
|
|
657
|
+
// ctx.configForms.get() binds a live ConfigFormController that follows
|
|
658
|
+
// the shared describe mirror (ConfigForms.get → new ConfigFormController
|
|
659
|
+
// + mirror.ensure), so a card registered before the namespace appears
|
|
660
|
+
// flips to the toggle the moment the host starts serving it; while it
|
|
661
|
+
// does not, the card renders its `settings.unavailable` line — visible
|
|
662
|
+
// and diagnosable instead of absent.
|
|
663
|
+
ctx.effect(() => ctx.slots.inject('plugins.item', () => ctx.slots.register({
|
|
664
|
+
name: 'plugins.item',
|
|
665
|
+
id: SETTINGS_ENTRY_ID,
|
|
666
|
+
order: 80,
|
|
667
|
+
label: () => t('settings.cardLabel'),
|
|
668
|
+
locale: NS,
|
|
669
|
+
inject: settingsInject,
|
|
670
|
+
}, SettingsCard)), 'dsh-cybernetics: plugins-page settings card');
|
|
671
|
+
// The dispatch key bundles use the profile dependency name (the value
|
|
672
|
+
// in dsh.profile.bundles = `dsh-cybernetics`); register the scoped
|
|
673
|
+
// package manifest name too, so either resolution finds the card.
|
|
674
|
+
for (const bundleKey of ['dsh-cybernetics', '@mars.liu/dsh-cybernetics']) {
|
|
675
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
676
|
+
name: 'plugins.row.config',
|
|
677
|
+
key: bundleKey + '#' + SETTINGS_ENTRY_ID,
|
|
678
|
+
locale: NS,
|
|
679
|
+
inject: settingsInject,
|
|
680
|
+
}, SettingsCard));
|
|
681
|
+
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
|
682
|
+
name: 'plugins.bundle.config',
|
|
683
|
+
key: bundleKey,
|
|
684
|
+
locale: NS,
|
|
685
|
+
inject: settingsInject,
|
|
686
|
+
}, SettingsCard));
|
|
687
|
+
}
|
|
688
|
+
|
|
405
689
|
// Conversation-view tab, same slot the trajectory/context tabs use.
|
|
406
690
|
// `t` reaches the view as a slot-provided prop from `locale: NS`
|
|
407
691
|
// (PropsLocale) — do NOT also pass a hand-bound t through inject:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mars.liu/dsh-cybernetics",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -33,12 +33,16 @@
|
|
|
33
33
|
"@deepseek-ai/dsh-client-locale",
|
|
34
34
|
"@deepseek-ai/dsh-client-ui-conversation",
|
|
35
35
|
"@deepseek-ai/dsh-client-ui-renderer",
|
|
36
|
-
"@deepseek-ai/dsh-client-ui-session"
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
37
38
|
],
|
|
38
39
|
"platform": "web",
|
|
39
40
|
"immediately": true
|
|
40
41
|
}
|
|
41
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.5"
|
|
45
|
+
},
|
|
42
46
|
"peerDependencies": {
|
|
43
47
|
"@deepseek-ai/dsh-session": "*"
|
|
44
48
|
}
|
package/src/controller.ts
CHANGED
|
@@ -32,6 +32,24 @@ export interface RoleSpec {
|
|
|
32
32
|
model?: string
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Parse a flat agent-model field ("provider/model" or a bare "model" name)
|
|
37
|
+
* into a workflow `agent()` override. Empty/whitespace/undefined → undefined,
|
|
38
|
+
* which the caller must treat as「与 session agent 一致」(no override handed
|
|
39
|
+
* to the round script — the workflow inherits the calling model).
|
|
40
|
+
*/
|
|
41
|
+
export function parseAgentModelSpec(value?: string): RoleSpec | undefined {
|
|
42
|
+
const raw = (value ?? '').trim()
|
|
43
|
+
if (!raw) return undefined
|
|
44
|
+
const slash = raw.indexOf('/')
|
|
45
|
+
if (slash > 0 && slash < raw.length - 1) {
|
|
46
|
+
const provider = raw.slice(0, slash).trim()
|
|
47
|
+
const model = raw.slice(slash + 1).trim()
|
|
48
|
+
if (provider && model) return { provider, model }
|
|
49
|
+
}
|
|
50
|
+
return { model: raw }
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
/** Per-role sub-agent configuration: 'default'/'verify'/'triage' plus named task roles. */
|
|
36
54
|
export interface RolesConfig {
|
|
37
55
|
default?: RoleSpec
|
|
@@ -112,6 +130,68 @@ export interface GoalRevisionRecord {
|
|
|
112
130
|
reason: string
|
|
113
131
|
/** Epoch milliseconds of the revision. */
|
|
114
132
|
at: number
|
|
133
|
+
/**
|
|
134
|
+
* v0.7: how the old goal's in-flight round was handled, and why. Present on
|
|
135
|
+
* every revision so the audit trail records the decision basis, not just the
|
|
136
|
+
* outcome — see `decideAbort` for the decision tree.
|
|
137
|
+
*/
|
|
138
|
+
abort?: { abort: boolean; mode: 'auto' | 'explicit'; basis: string }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether `revise` aborts the old goal's in-flight task agent round, and on
|
|
143
|
+
* what grounds. Pure — the caller supplies the "is a round in flight" fact
|
|
144
|
+
* from its own `running` map (no new orchestration mechanism; this reads the
|
|
145
|
+
* state the controller already maintains).
|
|
146
|
+
*
|
|
147
|
+
* Decision tree (v0.7 all-goals mode; `abortPrevious` explicitly passed keeps
|
|
148
|
+
* the v0.6 manual semantics):
|
|
149
|
+
* 1. `abortPrevious` given explicitly → honor it verbatim (mode 'explicit').
|
|
150
|
+
* 2. Old goal has NO round in flight → no abort, start the successor directly.
|
|
151
|
+
* 3. Old round in flight AND the revision rewrites the goal itself (a new
|
|
152
|
+
* `objective`, or a different `depth`) → the old round is working toward a
|
|
153
|
+
* target that no longer exists — contradictory goals / write-domain
|
|
154
|
+
* conflict → abort it (the cancel path) so it cannot interleave writes.
|
|
155
|
+
* 4. Old round in flight, correction only (new brief / title / role on the
|
|
156
|
+
* same objective) → the old work is still valuable and its result can be
|
|
157
|
+
* reused → let the round finish naturally, no abort.
|
|
158
|
+
*/
|
|
159
|
+
export interface AbortDecision {
|
|
160
|
+
abort: boolean
|
|
161
|
+
mode: 'auto' | 'explicit'
|
|
162
|
+
basis: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function decideAbort(
|
|
166
|
+
previous: LoopState,
|
|
167
|
+
roundInFlight: boolean,
|
|
168
|
+
args: { abortPrevious?: boolean; objective?: string; depth?: 'quick' | 'full' },
|
|
169
|
+
): AbortDecision {
|
|
170
|
+
if (args.abortPrevious !== undefined) {
|
|
171
|
+
return {
|
|
172
|
+
abort: args.abortPrevious,
|
|
173
|
+
mode: 'explicit',
|
|
174
|
+
basis: args.abortPrevious
|
|
175
|
+
? '调用方显式要求中止旧轮(abortPrevious: true,复用 cancel 路径)'
|
|
176
|
+
: '调用方显式要求保留旧轮(abortPrevious: false,跑完自然结束)',
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!roundInFlight) {
|
|
180
|
+
return { abort: false, mode: 'auto', basis: '旧 task agent 无在跑轮(查 goal 板 running 状态),直接启动新 goal,无需中止' }
|
|
181
|
+
}
|
|
182
|
+
const retargets = args.objective !== undefined || (args.depth !== undefined && args.depth !== (previous.depth ?? 'full'))
|
|
183
|
+
if (retargets) {
|
|
184
|
+
return {
|
|
185
|
+
abort: true,
|
|
186
|
+
mode: 'auto',
|
|
187
|
+
basis: '旧轮在跑且修订改写了目标本身(objective/depth 变化,目标矛盾/写域冲突)——自动中止旧轮,避免旧任务继续写入已被取代的方向',
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
abort: false,
|
|
192
|
+
mode: 'auto',
|
|
193
|
+
basis: '旧轮在跑但任务目标未变(仅 brief/title/role 修正,旧工作仍可复用/可并行)——等待旧轮自然结束,结果由新 goal 继承',
|
|
194
|
+
}
|
|
115
195
|
}
|
|
116
196
|
|
|
117
197
|
/** Input for the append-only goal creation path. */
|
package/src/index.ts
CHANGED
|
@@ -25,8 +25,9 @@ import { randomUUID } from 'node:crypto'
|
|
|
25
25
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import { fileURLToPath } from 'node:url'
|
|
28
|
+
import z from '@deepseek-ai/schemastery'
|
|
28
29
|
import {
|
|
29
|
-
STATE_DIR, createGoal, foldRound, runRound, startRound, supersedeGoal, writeRevisionLog,
|
|
30
|
+
STATE_DIR, createGoal, decideAbort, foldRound, parseAgentModelSpec, runRound, startRound, supersedeGoal, writeRevisionLog,
|
|
30
31
|
} from './controller.ts'
|
|
31
32
|
import type {
|
|
32
33
|
ControllerConfig, ControllerDecision, LoopState, ResumableReason, RoundResult, RolesConfig,
|
|
@@ -68,6 +69,10 @@ export interface Config extends ControllerConfig {
|
|
|
68
69
|
implicitTrigger?: boolean
|
|
69
70
|
/** Per-role sub-agent model/provider overrides. */
|
|
70
71
|
roles?: RolesConfig
|
|
72
|
+
/** Flat task-agent model override ("provider/model" or bare model); empty = inherit session agent. */
|
|
73
|
+
taskAgentModel?: string
|
|
74
|
+
/** Flat verify-agent model override (same format); empty = inherit session agent. */
|
|
75
|
+
verifyAgentModel?: string
|
|
71
76
|
/**
|
|
72
77
|
* Hard orchestration gate (default false): hide execution tools from
|
|
73
78
|
* TOP-LEVEL session agents at prompt-assembly time, so the session agent
|
|
@@ -89,12 +94,83 @@ export interface Config extends ControllerConfig {
|
|
|
89
94
|
gateAllowlist?: string[]
|
|
90
95
|
/** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
|
|
91
96
|
gateAllowlistExtra?: string[]
|
|
97
|
+
/**
|
|
98
|
+
* All-goals mode (v0.7, default false): EVERY user request — including
|
|
99
|
+
* trivial read-only questions and lightweight analysis — must go through a
|
|
100
|
+
* cybernetics goal first; in-place analysis by the session agent is banned.
|
|
101
|
+
* Off (default) keeps the v0.6 implicit-trigger behavior exactly unchanged.
|
|
102
|
+
*/
|
|
103
|
+
allGoalsMode?: boolean
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
106
|
+
// Under the host's 0.1.7 settings model, an exported schemastery `Config`
|
|
107
|
+
// schema IS the plugin entry's settings page: the Web editor derives the
|
|
108
|
+
// plugin config form from it and serves it through the shared configuration
|
|
109
|
+
// forms (see @linxin666/dsh-ssh and @linxin666/dsh-usage for the same
|
|
110
|
+
// pattern). At least one field must be `.volatile()` for the page to be
|
|
111
|
+
// served at all; a volatile edit is committed into the running fiber's
|
|
112
|
+
// reference without a remount and announced via `loader/volatile-update`.
|
|
113
|
+
// allGoalsMode is volatile so the panel toggle is live; hardGate and the
|
|
114
|
+
// gate/roles fields are deliberately non-volatile — they change structural
|
|
115
|
+
// wiring (tool stripping listener, per-role routing), so an edit remounts
|
|
116
|
+
// the row, which is the correct behavior for them.
|
|
117
|
+
export const Config = z.object({
|
|
118
|
+
/** Model-facing tool name. */
|
|
119
|
+
toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
|
|
120
|
+
/** Inject the implicit goal-loop guidance section. */
|
|
121
|
+
implicitTrigger: z.boolean().default(true).description('注入隐式 goal-loop 系统提示段(缺省开启)。'),
|
|
122
|
+
/** Per-role sub-agent model/provider overrides: role -> {provider, model}. */
|
|
123
|
+
roles: z.dict(z.object({
|
|
124
|
+
provider: z.string(),
|
|
125
|
+
model: z.string(),
|
|
126
|
+
})).description('按角色的子 agent provider/model 覆盖:role -> {provider, model}(如 default/verify/triage/frontend)。'),
|
|
127
|
+
/**
|
|
128
|
+
* Flat task-agent model override ("provider/model" or a bare model name).
|
|
129
|
+
* Empty = inherit the session agent. Merged over roles.default at wiring
|
|
130
|
+
* time (parseAgentModelSpec) — the flat fields are the settings-panel face;
|
|
131
|
+
* roles stays the canonical runtime shape the round script consumes.
|
|
132
|
+
* Volatile (v0.7.7): the host settings form (@deepseek-ai/dsh-settings
|
|
133
|
+
* volatileForm, lib/index.js:122-131) serves ONLY `.volatile()` fields, and
|
|
134
|
+
* its write() refuses any non-volatile path ("Config field ... is not
|
|
135
|
+
* volatile") — a plain field is invisible AND unwritable on the plugins
|
|
136
|
+
* page. Volatile commit keeps the running instance live; the value is read
|
|
137
|
+
* per round start (readConfigField unwrap below), so an edit takes effect
|
|
138
|
+
* on the next goal round without a remount.
|
|
139
|
+
*/
|
|
140
|
+
taskAgentModel: z.string().default('').volatile().description('task agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
|
|
141
|
+
/** Flat verify-agent model override; same format, fallback and volatile semantics as taskAgentModel. */
|
|
142
|
+
verifyAgentModel: z.string().default('').volatile().description('verify agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
|
|
143
|
+
/** Hard orchestration gate (default false). */
|
|
144
|
+
hardGate: z.boolean().default(false).description('硬编排门:装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
|
|
145
|
+
/** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
|
|
146
|
+
gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
|
|
147
|
+
/** Gate semantics. */
|
|
148
|
+
gateMode: z.union(['denylist', 'allowlist'] as const).default('denylist').description('门语义:denylist 隐藏 gatedTools;allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身。'),
|
|
149
|
+
/** Allowlist-mode survival set (default DEFAULT_GATE_ALLOWLIST). */
|
|
150
|
+
gateAllowlist: z.array(z.string()).description('allowlist 模式生存工具集(缺省内置 DEFAULT_GATE_ALLOWLIST)。'),
|
|
151
|
+
/** Extra tool names unioned onto the allowlist. */
|
|
152
|
+
gateAllowlistExtra: z.array(z.string()).description('并集追加进 allowlist 的额外工具名。'),
|
|
153
|
+
/**
|
|
154
|
+
* All-goals mode (v0.7, default false): every user request must go through
|
|
155
|
+
* a cybernetics goal first. Volatile: the settings-panel toggle commits
|
|
156
|
+
* into the running instance without a remount.
|
|
157
|
+
*/
|
|
158
|
+
allGoalsMode: z.boolean().default(false).volatile()
|
|
159
|
+
.description('全目标模式(v0.7,缺省关):所有用户请求(含只读问题)都必须先经 cybernetics goal。开关在面板上保存后即时生效,无需重载。'),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** One resolved config field: a live reference (volatile), or a plain value. */
|
|
163
|
+
type ConfigField<T> = { get(): T } | T
|
|
164
|
+
|
|
165
|
+
/** Read one resolved config field, following the live reference the schema produces for volatile fields. */
|
|
166
|
+
function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
|
|
167
|
+
if (field === undefined) return fallback
|
|
168
|
+
if (typeof field === 'object' && field !== null && typeof (field as { get?: unknown }).get === 'function') {
|
|
169
|
+
const value = (field as { get(): T }).get()
|
|
170
|
+
return value === undefined ? fallback : value
|
|
171
|
+
}
|
|
172
|
+
return field as T
|
|
173
|
+
}
|
|
98
174
|
|
|
99
175
|
/**
|
|
100
176
|
* Multi-goal loop registry: session -> goalId -> loop. v0.3 lifts the
|
|
@@ -318,6 +394,42 @@ yourself — you manage goals and the task agents that execute them. Classify ea
|
|
|
318
394
|
'round-cap'/'failed' = report status. Never mark a goal complete yourself; completion is the user's act.
|
|
319
395
|
Use action 'list' to see every goal on this session's board with its status, round and subgoal.`
|
|
320
396
|
|
|
397
|
+
/**
|
|
398
|
+
* v0.7 all-goals mode addendum: turns the implicit trigger from "suggest a
|
|
399
|
+
* goal for substantial tasks" into "every request is a goal". Contains the
|
|
400
|
+
* four routing rules the protocol and tests pin down:
|
|
401
|
+
* (1) everything enters a goal; (2) simple read-only → 'quick' single round;
|
|
402
|
+
* (3) complex analysis / write tasks → progressive multi-round ('full');
|
|
403
|
+
* (4) the revise auto-abort decision tree.
|
|
404
|
+
*/
|
|
405
|
+
const ALL_GOALS_GUIDANCE = `
|
|
406
|
+
ALL-GOALS MODE ACTIVE (routing protocol — no exceptions, no in-place analysis):
|
|
407
|
+
- RULE 1 — EVERYTHING ENTERS A GOAL: for ANY user request (question, lookup, analysis, change), first
|
|
408
|
+
create a goal via cybernetics (action 'start' with title + brief), then let the task agent produce the
|
|
409
|
+
answer. NEVER answer a request by doing the task work yourself with read tools — even a one-file lookup
|
|
410
|
+
or a quick calculation goes through the loop. If a change is requested while the hard gate is off, the
|
|
411
|
+
protocol still forbids in-place edits: delegate.
|
|
412
|
+
- RULE 2 — SIMPLE READ-ONLY REQUEST → depth 'quick': one task-agent round, no verify/triage, feed the
|
|
413
|
+
result straight back to the user (bounded lookups, single-file reads, small queries).
|
|
414
|
+
- RULE 3 — COMPLEX ANALYSIS or WRITE TASK → depth 'full', progressive multi-round: launch with action
|
|
415
|
+
'start' (async), then 'collect' each settled round and advance the subgoal round by round (task →
|
|
416
|
+
verify → triage). Analysis: data fetching to disk, datasets, statistics, multi-step query planning.
|
|
417
|
+
Write tasks additionally rely on the hard gate: the session agent must NEVER execute a change itself —
|
|
418
|
+
delegation through the goal loop is the only path, however small the change.
|
|
419
|
+
- RULE 4 — REVISE DECISION TREE (updating an existing goal stays append-only: a new goal with
|
|
420
|
+
parentGoalId, old goal superseded; 'revise' is the only goal-change verb). Decide abortPrevious by the
|
|
421
|
+
old task agent's state (check with action 'list'):
|
|
422
|
+
(a) old round STILL RUNNING and the revision contradicts it (objective/depth rewritten — conflicting
|
|
423
|
+
goals / write-domain overlap) → let 'revise' auto-abort: do not pass abortPrevious; the plugin
|
|
424
|
+
cancels the old round automatically;
|
|
425
|
+
(b) old round STILL RUNNING but the correction keeps the objective (brief/title/role only) and the old
|
|
426
|
+
work stays valuable / parallelizable / reusable → do not pass abortPrevious; the plugin waits for
|
|
427
|
+
the old round to finish naturally;
|
|
428
|
+
(c) old task agent ALREADY FINISHED (goal not running on the board) → just call 'revise'; no abort is
|
|
429
|
+
needed and none is attempted.
|
|
430
|
+
The chosen branch and its reason are recorded in the .cybernetics/revisions-<newGoalId>.jsonl audit
|
|
431
|
+
line (abort.mode = auto|explicit + basis). Passing abortPrevious explicitly keeps manual control.`
|
|
432
|
+
|
|
321
433
|
/** Tool names hidden from top-level session agents when the hard gate is on. */
|
|
322
434
|
const DEFAULT_GATED_TOOLS = ['bash', 'edit', 'write']
|
|
323
435
|
|
|
@@ -373,6 +485,10 @@ not find it, that is the gate working as intended — delegate instead.`
|
|
|
373
485
|
export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
374
486
|
const toolName = config.toolName ?? 'cybernetics'
|
|
375
487
|
const hardGate = config.hardGate === true
|
|
488
|
+
// allGoalsMode is a volatile field: under the loader's schema resolution it
|
|
489
|
+
// arrives as a live reference whose value the settings panel commits in
|
|
490
|
+
// place (no remount), so read it at use time instead of capturing it once.
|
|
491
|
+
const allGoalsMode = () => readConfigField(config.allGoalsMode, false) === true
|
|
376
492
|
const disposers: Array<() => void> = []
|
|
377
493
|
|
|
378
494
|
// v0.6: register the multi-goal session projection, so every goal on the
|
|
@@ -389,13 +505,24 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
389
505
|
if (typeof projectionDisposer === 'function') disposers.push(projectionDisposer)
|
|
390
506
|
}
|
|
391
507
|
|
|
392
|
-
|
|
393
|
-
|
|
508
|
+
// The implicit guidance section embeds the volatile allGoalsMode wording,
|
|
509
|
+
// so it must be re-derived when a settings-panel toggle is committed into
|
|
510
|
+
// the running instance (the loader announces loader/volatile-update on this
|
|
511
|
+
// fiber instead of remounting; same re-derive pattern as dsh-ssh's sync()).
|
|
512
|
+
let disposeSection: (() => void) | undefined
|
|
513
|
+
function registerGuidanceSection() {
|
|
514
|
+
if (disposeSection !== undefined) disposeSection()
|
|
515
|
+
disposeSection = undefined
|
|
516
|
+
if (config.implicitTrigger === false) return
|
|
517
|
+
disposeSection = ctx.systemPrompt.section({
|
|
394
518
|
name: `tool:${toolName}`,
|
|
395
519
|
order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
|
|
396
|
-
text: IMPLICIT_GUIDANCE + (hardGate ? HARD_GATE_GUIDANCE : ''),
|
|
520
|
+
text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') + (hardGate ? HARD_GATE_GUIDANCE : ''),
|
|
397
521
|
})
|
|
398
522
|
}
|
|
523
|
+
registerGuidanceSection()
|
|
524
|
+
disposers.push(() => { if (disposeSection !== undefined) disposeSection() })
|
|
525
|
+
ctx.on?.('loader/volatile-update', () => { registerGuidanceSection() })
|
|
399
526
|
|
|
400
527
|
if (hardGate) {
|
|
401
528
|
// The hard gate: a system-prompt/assemble waterfall listener that strips
|
|
@@ -403,12 +530,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
403
530
|
// (task/verify/triage) and team teammates are never in agents.roots(),
|
|
404
531
|
// so their assemblies pass through untouched with full tool access.
|
|
405
532
|
const allowlistMode = config.gateMode === 'allowlist'
|
|
533
|
+
// NOTE: schema-resolved config turns an unset z.array field into [] (not
|
|
534
|
+
// undefined), so fall back on length, not on ?? — otherwise the gate
|
|
535
|
+
// would silently keep/gate nothing.
|
|
406
536
|
const keep = new Set([
|
|
407
|
-
...(config.gateAllowlist
|
|
537
|
+
...(config.gateAllowlist?.length ? config.gateAllowlist : DEFAULT_GATE_ALLOWLIST),
|
|
408
538
|
...(config.gateAllowlistExtra ?? []),
|
|
409
539
|
toolName,
|
|
410
540
|
])
|
|
411
|
-
const gated = new Set(config.gatedTools
|
|
541
|
+
const gated = new Set(config.gatedTools?.length ? config.gatedTools : DEFAULT_GATED_TOOLS)
|
|
412
542
|
const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
|
|
413
543
|
const scope = context?.scope
|
|
414
544
|
if (!scope) return next()
|
|
@@ -524,7 +654,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
524
654
|
abortPrevious: {
|
|
525
655
|
type: 'boolean',
|
|
526
656
|
description:
|
|
527
|
-
'revise:是否中止旧 goal 正在运行的 task agent
|
|
657
|
+
'revise:是否中止旧 goal 正在运行的 task agent 轮。缺省时按决策树自动判断(查 goal 板:旧轮在跑且目标被改写→自动中止;旧轮在跑但目标不变→等待自然结束;旧轮未跑→直启);' +
|
|
658
|
+
'显式传 true/false 保持人工控制(true 复用 cancel 路径)。',
|
|
528
659
|
},
|
|
529
660
|
executor: {
|
|
530
661
|
type: 'string',
|
|
@@ -540,6 +671,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
540
671
|
goalId: { type: 'string' },
|
|
541
672
|
previousGoalId: { type: 'string' },
|
|
542
673
|
revised: { type: 'boolean' },
|
|
674
|
+
abort: { type: 'object' },
|
|
543
675
|
revision: { type: 'object' },
|
|
544
676
|
round: { type: 'number' },
|
|
545
677
|
decision: { type: 'object' },
|
|
@@ -725,10 +857,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
725
857
|
// it visible in the tab even when this call changes nothing else.
|
|
726
858
|
await publishBoard()
|
|
727
859
|
|
|
860
|
+
// Flat settings-panel model fields fold into the canonical roles shape:
|
|
861
|
+
// an unset field leaves its role untouched = inherit the session agent.
|
|
862
|
+
// Both fields are volatile (v0.7.7), so the loader hands back live
|
|
863
|
+
// references — unwrap through readConfigField ('' = inherit) exactly
|
|
864
|
+
// like the allGoalsMode live ref above.
|
|
865
|
+
const roleOverrides: RolesConfig = { ...(config.roles ?? {}) }
|
|
866
|
+
const taskSpec = parseAgentModelSpec(readConfigField(config.taskAgentModel, ''))
|
|
867
|
+
if (taskSpec) roleOverrides.default = { ...roleOverrides.default, ...taskSpec }
|
|
868
|
+
const verifySpec = parseAgentModelSpec(readConfigField(config.verifyAgentModel, ''))
|
|
869
|
+
if (verifySpec) roleOverrides.verify = { ...roleOverrides.verify, ...verifySpec }
|
|
728
870
|
const controllerConfig: ControllerConfig = {
|
|
729
871
|
roundScriptPath: config.roundScriptPath ?? fileURLToPath(new URL('../workflows/round.js', import.meta.url)),
|
|
730
872
|
roundsPerSubgoal: config.roundsPerSubgoal,
|
|
731
|
-
roles:
|
|
873
|
+
roles: roleOverrides,
|
|
732
874
|
}
|
|
733
875
|
const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
|
|
734
876
|
|
|
@@ -805,7 +947,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
805
947
|
'revise the successor goal instead of forking the lineage',
|
|
806
948
|
)
|
|
807
949
|
}
|
|
808
|
-
|
|
950
|
+
// v0.7 abort policy: explicit abortPrevious keeps the v0.6 manual
|
|
951
|
+
// semantics; when the caller omits it, all-goals mode auto-decides via
|
|
952
|
+
// the decision tree (see decideAbort). With allGoalsMode off, the
|
|
953
|
+
// omitted flag means false exactly as before (backward compat) and the
|
|
954
|
+
// audit records that legacy default instead of a fabricated decision.
|
|
955
|
+
const roundInFlight = running.has(previous.goalId!)
|
|
956
|
+
const abortDecision = decideAbort(previous, roundInFlight, args)
|
|
957
|
+
const legacyDefault = args.abortPrevious === undefined && !allGoalsMode()
|
|
958
|
+
const effectiveAbort = legacyDefault
|
|
959
|
+
? { abort: false, mode: 'explicit' as const, basis: 'all-goals 模式未开启:abortPrevious 缺省沿用 v0.6 默认 false(不中止旧轮)' }
|
|
960
|
+
: abortDecision
|
|
961
|
+
const abortPrevious = args.abortPrevious !== undefined
|
|
962
|
+
? args.abortPrevious === true
|
|
963
|
+
: (allGoalsMode() ? abortDecision.abort : false)
|
|
809
964
|
const previousHandle = running.get(previous.goalId!)
|
|
810
965
|
let aborted: string | undefined
|
|
811
966
|
if (abortPrevious && previousHandle) {
|
|
@@ -833,7 +988,10 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
833
988
|
const at = Date.now()
|
|
834
989
|
let revisionLog: string | undefined
|
|
835
990
|
try {
|
|
836
|
-
revisionLog = await writeRevisionLog(root, newGoalId, {
|
|
991
|
+
revisionLog = await writeRevisionLog(root, newGoalId, {
|
|
992
|
+
from: previous.goalId!, to: newGoalId, reason, at,
|
|
993
|
+
abort: effectiveAbort,
|
|
994
|
+
})
|
|
837
995
|
} catch {
|
|
838
996
|
// Audit is observability: a read-only workspace must not break the
|
|
839
997
|
// revision (the lineage fields on the board still carry the change).
|
|
@@ -846,6 +1004,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
846
1004
|
round,
|
|
847
1005
|
started: true,
|
|
848
1006
|
...(aborted ? { aborted } : {}),
|
|
1007
|
+
abort: effectiveAbort,
|
|
849
1008
|
revision: { from: previous.goalId, to: newGoalId, reason, at },
|
|
850
1009
|
...(revisionLog ? { revisionLog } : {}),
|
|
851
1010
|
}
|