@mars.liu/dsh-cybernetics 0.8.4 → 0.8.10
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 +26 -4
- package/README.zh-CN.md +26 -4
- package/docs/CHANGELOG.md +42 -0
- package/docs/approval-relay-investigation.md +98 -0
- package/docs/diag-session-9e7abdf0-20261104.md +50 -0
- package/lib/client.js +122 -13
- package/package.json +1 -1
- package/src/controller.ts +5 -1
- package/src/executor.ts +4 -1
- package/src/index.ts +422 -32
package/README.md
CHANGED
|
@@ -42,9 +42,30 @@ flowchart TB
|
|
|
42
42
|
|
|
43
43
|
When enabled, injects the "ALL-GOALS MODE ACTIVE" routing protocol: **every user request first creates a goal, then executes**; the session agent is forbidden from completing tasks in place with read tools — even a single-file lookup or one calculation goes through the loop. Simple read-only requests → a single `quick` round; complex analysis / write tasks → progressive multi-round `full` (async `start` + round-by-round `collect` convergence). When the mode is off, the prompt segments and revise behavior are exactly the same as v0.6.
|
|
44
44
|
|
|
45
|
-
### Hard gate (`hardGate`, default
|
|
45
|
+
### Hard gate (`hardGate`, default true — active out of the box; v0.8.10: toggleable on the settings card)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
**Hot toggle (v0.8.10).** `hardGate` is a **volatile** field: the plugins-page settings card renders a live switch for it. Saving the toggle commits into the running instance and the plugin disposes or (re)installs the gate listeners (assembly strip + dispatch deny + approval relay) immediately — no restart, no plugin reload. A hot false→true re-derives the mode from the current `gateMode` resolution (explicit config wins; otherwise the structural allowlist). The other gate fields (`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`) remain non-volatile config-document fields; edit those in the profile config document (saving triggers a plugin reload). Deployment nuance: the first time this build ships, the running host still holds the pre-0.8.10 code — restart the host once to load it; after that the toggle is hot (`lib/client.js` needs only a browser refresh).
|
|
48
|
+
|
|
49
|
+
**Effective mode (v0.8.6, default-safe).** When `hardGate` is on and `gateMode` is not configured, the gate runs in **`allowlist`** mode: the top-level session agent keeps only read + conversation + presentation + goal/orchestration verbs (the built-in `DEFAULT_GATE_ALLOWLIST`, extendable via `gateAllowlist` / `gateAllowlistExtra`), and every change-class tool is removed at assembly **and denied at dispatch**. This is the default stance because the old `denylist` default was a trap: it hid only the three names in `gatedTools` (bash/edit/write) and left `bsk_*`, `taskboard_*`, `schedule_*`, `farm_*`, `uno_*`, `ppt_*`, `config_restore` and the rest of the mutation long tail callable — a hard gate in name only. `hardGate: false` installs no gate, exactly as before. An explicitly configured `gateMode` is honored verbatim; the schema has **no default** for `gateMode`, so "unset" stays distinguishable from "explicitly denylist".
|
|
50
|
+
|
|
51
|
+
**Explicit `gateMode: 'denylist'` (residual risk, opt-in).** Only the names in `gatedTools` (default bash/edit/write) are removed. On a top-level agent the following remain available, at least: `bsk_*` (browser automation, including `bsk_session` start/stop), `taskboard_*` writes (create/update/move/comment/checklist/delete), `schedule_*` writes, `farm_*` service lifecycle (start/restart/stop/register/unregister), `uno_*` and `ppt_*` generators, `config_restore`, `subagent`/team spawning, `knowledge_save`, `job_kill` — anything not listed in `gatedTools`, and the set grows with every installed plugin, so a denylist can never be complete. Choose it only if you consciously accept that risk (e.g. the session agent legitimately manages services), and list every mutation tool you need in `gatedTools`. In this mode the injected HARD GATE guidance states the partial stance explicitly (only `gatedTools` removed) and no longer claims that all change tools are structurally unavailable.
|
|
52
|
+
|
|
53
|
+
The task/verify/triage sub agents are never subject to the gate and keep all tools.
|
|
54
|
+
|
|
55
|
+
### Sub-agent approval relay (`gateApprovalRelay`, default true)
|
|
56
|
+
|
|
57
|
+
The host pins every delegated child (workflow subagent, teammate) to the `never` approval policy at delegation — each ask is deterministically rejected **before any answerer sees it**, so a task agent that needs a sandbox escalation (typically writing outside the session workspace, e.g. `~/.dsh/profiles/web/cordis.patch.yml`) fails without the user ever being asked. With `gateApprovalRelay` on (default, hardGate companion), the plugin:
|
|
58
|
+
|
|
59
|
+
1. **Unpins the child policy at creation** — on `agent/created` for any delegated child it appends one durable `approval/policy {policy:'ask'}` event to the child session (a later event wins over the delegation-seeded pin, per the host's own fold rule). Without this the approval service short-circuits every ask to `rejected` before any answerer runs.
|
|
60
|
+
2. **Re-issues the ask on the top-level session agent** — the plugin registers its `approval/request` answerer **outermost** (`prepend: true`). That is the fix for the last hop: the host's `api-remotes` registers an `approval/request` forwarder at boot, before every user plugin, and the browser's approval answerer claims a child request and awaits a per-session panel that is invisible while the user watches the parent conversation, never calling `next()` — so a relay registered later can never run at all. Running first, the relay resolves the child's **top-level live ancestor** (via the durable `parentSession` chain) and calls the host's own `ApprovalService.request` on it: the request surfaces as the native **Allow once / Reject** panel in the conversation the user is already watching, and is audited as `approval/asked` + `approval/decided` on that session. The child's tool-call id is deliberately not forwarded, and the panel headline names the sub-agent (localized `displayReason`).
|
|
61
|
+
|
|
62
|
+
**Fallback chain and `unavailable` semantics (v0.8.8)**: if the top-level turn is already closed (a continuable child outliving it), or the native panel answers `unavailable` (no answering client claimed the frame — e.g. the browser never saw it), the relay falls through to a visible `ask_user_question` card ("子代理审批透传") on the same top-level agent — a native `unavailable` is **not** terminal. Only when the question channel is unavailable too does the request end as a terminal `unavailable`. With no live top-level agent at all it falls through to the native chain. Denial semantics stay explicit: a relay failure answers `rejected`, an aborted child run answers `cancelled`, and the child model always receives a real outcome through its approval audit pair (never a silent success).
|
|
63
|
+
|
|
64
|
+
**Observability / troubleshooting (v0.8.9)**: every relay decision is logged twice — one host-logger line, `cybernetics approval-relay: path=… outcome=… root=… elapsed=…ms` (`console.debug` fallback), and one JSON line appended to `<workspace>/.cybernetics/relay-log.jsonl` (`{ts, root, path, outcome, elapsedMs}`). The file is the acceptance ground truth, readable without host stdout or browser console access. `path` is `native-panel` (the top-level native Allow once / Reject panel), `question-card` (the visible fallback card) or `next-passthrough` (no live top-level agent → native chain). If a child fails fast with `decided unavailable` and **no** entry at all appears in `relay-log.jsonl`, the request never reached the relay (first confirm the host actually reloaded the plugin build); a `path=native-panel outcome=unavailable` entry followed by a `path=question-card` entry means the native frame left the host but no browser claimed it — the user still sees the question card. Note: the question-card fallback resolves the host's `userQuestions` service via `ctx.get` (the inject-free reflective read, same as `dsh-plan-mode`); earlier builds resolved it by plain property access, which cordis rejects for non-injected services — the v0.8.8 live failure ("card never appeared, terminal `unavailable` within 12 ms") was exactly this.
|
|
65
|
+
|
|
66
|
+
Set `gateApprovalRelay: false` to keep the strict deterministic-rejection stance (CI/unattended deployments). This field is a hardGate companion and is **non-volatile / config-file maintained**: edit it in the profile config document; a save triggers a plugin reload.
|
|
67
|
+
|
|
68
|
+
Host-side evidence (file:line), the rejected alternatives and the reproduction are recorded in [docs/approval-relay-investigation.md](docs/approval-relay-investigation.md); `test/relay-host-probe.mjs` re-runs the real cordis waterfall + real `ApprovalService` + this plugin's `apply()` as a regression probe.
|
|
48
69
|
|
|
49
70
|
### Append-only goals and the revise decision tree
|
|
50
71
|
|
|
@@ -126,7 +147,8 @@ Configure directly on this plugin's card on the Web editor's "Plugins" page —
|
|
|
126
147
|
- **All-goals mode** (`allGoalsMode`, volatile): toggle, takes effect immediately on save, prompt segments remount instantly;
|
|
127
148
|
- **task/verify/triage agent models** (`taskAgentModel` / `verifyAgentModel` / `triageAgentModel`, volatile): dropdown-backed text fields — pick from the live system model list (shown as `providerName · provider/model`) or type "provider/model" / a bare model name; Enter or blur saves, empty inherits from the session agent (triage = the convergence-decision role, full rounds only). A configured value missing from the live model list shows an unavailable warning (runtime falls back to the session agent model) with a one-click Clear;
|
|
128
149
|
- **Recursion depth / parallel cap** (`roundsPerSubgoal` integer ≥1 / `maxConcurrentRounds` integer ≥0, volatile): number fields, Enter or blur saves, blank restores the default (an `unset` write);
|
|
129
|
-
- **Hard gate
|
|
150
|
+
- **Hard gate** (`hardGate`, volatile since v0.8.10): a boolean toggle on the card — strips write/execution tools from the top-level agent at assembly and routes every change through the goal loop; takes effect immediately on save (gate listeners hot-installed/disposed), no restart needed;
|
|
151
|
+
- **Other gate fields** (`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`, non-volatile): not served to the card; edit them in the profile config document (saving triggers a plugin reload). `gateMode` has no default: unset + `hardGate: true` runs `allowlist`; an explicit `denylist` is honored and carries the residual risk documented above.
|
|
130
152
|
|
|
131
153
|
### Advanced configuration: roles (profile yaml, agent-guided)
|
|
132
154
|
|
|
@@ -185,7 +207,7 @@ Companion auditing: each round's structured result lands in `.cybernetics/round-
|
|
|
185
207
|
## Known Limitations
|
|
186
208
|
|
|
187
209
|
1. **Host defect in settings writes**: as described in the "Plugin-page configuration" section, on this runtime, card saves may be rejected by the host before reaching disk (upstream [#12045](https://github.com/deepseek-ai/dsh/discussions/12045)); workarounds: restart the host + hard-refresh the browser, or manual patch rows + restart.
|
|
188
|
-
2. **Save-semantics difference between volatile and non-volatile fields**: volatile fields (`allGoalsMode`, `taskAgentModel`, `verifyAgentModel`, `triageAgentModel`, `roundsPerSubgoal`, `maxConcurrentRounds`) take effect immediately on save without remount; non-volatile fields (`roles` since v0.8.4, `
|
|
210
|
+
2. **Save-semantics difference between volatile and non-volatile fields**: volatile fields (`allGoalsMode`, `hardGate` since v0.8.10, `taskAgentModel`, `verifyAgentModel`, `triageAgentModel`, `roundsPerSubgoal`, `maxConcurrentRounds`) take effect immediately on save without remount; non-volatile fields (`roles` since v0.8.4, `gateMode`, `gatedTools`, `gateAllowlist*`, `gateApprovalRelay`, `toolName`, `implicitTrigger`) trigger a plugin-row remount on save, requiring a host reload to complete. Moreover, the host settings form only serves and only accepts volatile paths — if the running host's schema is too old, even volatile writes are rejected (a restart fixes it).
|
|
189
211
|
3. **`link:` installation caveats**: the client bundle's registration id must equal the package.json `name` (`@mars.liu/dsh-cybernetics`); the loader row id may use the short name `dsh-cybernetics`. The plugin must hang at the **top level** of the profile's loader entries (the client scan enumerates only top-level rows); `src/` changes require a host reload, `lib/` changes require a host restart + browser refresh.
|
|
190
212
|
4. Other protocol boundaries (the hard gate operating at the assembly layer rather than the execution layer, dsh-goal's focus slot not migrating on supersede, etc.) are covered in the "Known Boundaries" subsections of [docs/protocol.md](docs/protocol.md).
|
|
191
213
|
|
package/README.zh-CN.md
CHANGED
|
@@ -42,9 +42,30 @@ flowchart TB
|
|
|
42
42
|
|
|
43
43
|
开启后注入「ALL-GOALS MODE ACTIVE」路由协议:**任何用户请求一律先建 goal 再执行**,session agent 禁止用读工具就地完成任务——哪怕单文件查询或一次计算也走回路。简单只读请求 → `quick` 单轮;复杂分析/写任务 → `full` 多轮渐进(`start` 异步启动 + 逐轮 `collect` 收敛)。关闭模式时提示段与 revise 行为与 v0.6 完全一致。
|
|
44
44
|
|
|
45
|
-
### 硬门(`hardGate`,默认
|
|
45
|
+
### 硬门(`hardGate`,默认 true——安装即激活;v0.8.10 起设置卡片可开关)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
**热切换(v0.8.10)**:`hardGate` 已是 **volatile** 字段——插件页设置卡片上有实时开关。保存即把新值提交进运行实例,插件随即卸载或重装门禁监听(装配摘工具 + 分发拒绝 + 审批透传),**即时生效,无需重启、无需插件重载**。从关到开的热启按当前 `gateMode` 解析结果接线(显式配置优先,否则结构性 allowlist)。其余门字段(`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`)仍是非 volatile 配置文档字段,请在 profile 配置文档中维护(保存触发插件重载)。部署注意:本构建首次上线时运行中宿主仍是旧代码——重启宿主一次加载新代码,此后开关即热生效(`lib/client.js` 只需刷新浏览器)。
|
|
48
|
+
|
|
49
|
+
**生效语义(v0.8.6,默认安全)**:`hardGate` 开启且未配置 `gateMode` 时,门按 **`allowlist`** 运行——顶层 session agent 只保留读 + 对话 + 展示 + goal/编排动词(内置 `DEFAULT_GATE_ALLOWLIST`,可经 `gateAllowlist`/`gateAllowlistExtra` 扩展),所有变更类工具在装配期被**移除**,并在**调用分发层被拒绝**。之所以改为这个默认:旧的 `denylist` 默认是个陷阱——它只隐藏 `gatedTools` 的三个名字(bash/edit/write),`bsk_*`、`taskboard_*`、`schedule_*`、`farm_*`、`uno_*`、`ppt_*`、`config_restore` 以及整个变更工具长尾全都仍可调用,名为硬门、实为半门。`hardGate: false` 与以前完全一致(不装门);显式配置的 `gateMode` 一律原样尊重;schema 不再给 `gateMode` 设默认值,因此「未设置」与「显式 denylist」可以被区分。
|
|
50
|
+
|
|
51
|
+
**显式 `gateMode: 'denylist'`(残留风险,需主动选择)**:只移除 `gatedTools` 中的工具名(默认 bash/edit/write)。顶层 agent 至少仍可使用:`bsk_*`(浏览器自动化,含 `bsk_session` 启停)、`taskboard_*` 写操作(create/update/move/comment/checklist/delete)、`schedule_*` 写操作、`farm_*` 服务生命周期(start/restart/stop/register/unregister)、`uno_*` 与 `ppt_*` 生成器、`config_restore`、`subagent`/team 派生、`knowledge_save`、`job_kill`——凡未被列入 `gatedTools` 的都在,且这个集合随每个新装插件增长,denylist 永远不可能完备。只有在你清醒接受该风险时才显式选择它(例如确实要 session agent 管理服务),并把需要的变更工具逐项补进 `gatedTools`。该模式下注入的 HARD GATE 提示会如实说明「只摘 `gatedTools`」的半门立场,不再假称所有改世界的工具都结构性不可用。
|
|
52
|
+
|
|
53
|
+
task/verify/triage 子 agent 从不在门禁范围内,保留全部工具。
|
|
54
|
+
|
|
55
|
+
### 子代理审批透传(`gateApprovalRelay`,默认 true)
|
|
56
|
+
|
|
57
|
+
宿主在委派边界把每个子代理(workflow 子代理、teammate)的审批策略**钉死为 `never`**——每次审批请求在任何 answerer 看到之前就被确定性拒绝,因此 task agent 需要沙箱越权(典型:写会话工作区之外的路径,如 `~/.dsh/profiles/web/cordis.patch.yml`)时用户根本没机会被询问。`gateApprovalRelay` 开启时(默认,hardGate 伴随项),插件做两件事:
|
|
58
|
+
|
|
59
|
+
1. **创建时解除子代理策略钉死**——监听 `agent/created`,对委派子代理在其 session 上追加一条持久 `approval/policy {policy:'ask'}` 事件(宿主的折叠规则保证后写事件覆盖委派种下的钉死值)。没有这一步,审批服务会在任何 answerer 运行之前就把请求确定性拒绝成 `rejected`。
|
|
60
|
+
2. **在顶层 session agent 上重新发起请求**——插件把 `approval/request` answerer 注册为**最外层**(`prepend: true`),这正是最后一跳的修复点:宿主的 `api-remotes` 在启动时(早于所有用户插件)注册了 `approval/request` 转发器,浏览器审批 answerer 会 claim 子代理请求并等待一张"用户根本看不见"的子会话面板、永不调用 `next()`——因此注册在其后的转发器永远轮不到执行。抢在最外层运行后,插件沿持久 `parentSession` 链找到子代理的**顶层活祖先**,直接调用宿主的 `ApprovalService.request`:请求以原生**允许一次 / 拒绝**面板出现在用户正在看的那个会话里,并在该 session 的 `approval/asked` + `approval/decided` 审计对中留痕。子代理的 tool call id 不向上转发;面板标题(本地化 `displayReason`)会写明是哪个子代理发的请求。
|
|
61
|
+
|
|
62
|
+
**回退链与 `unavailable` 语义(v0.8.8)**:若顶层回合已关闭(continuable 子代理比父回合活得久),或原生面板应答 `unavailable`(没有应答端 claim 该请求——例如 frame 根本没被浏览器认领),relay 会继续回退到同一顶层 agent 上**可见的** `ask_user_question` 卡片(「子代理审批透传」)——原生 `unavailable` **不是**终态。仅当问题卡片通道也不可用时才以终态 `unavailable` 结束;完全没有活的顶层 agent 则回落原生应答链。拒绝语义始终显式:转发失败答 `rejected`、子代理已中止答 `cancelled`,子代理模型总能经自己的审批审计对拿到真实结果,绝不会静默成功。
|
|
63
|
+
|
|
64
|
+
**可观测性 / 排障(v0.8.9)**:relay 每次决策记录两处——宿主 logger 一行 `cybernetics approval-relay: path=… outcome=… root=… elapsed=…ms`(无 logger 时 `console.debug` 兜底),以及 `<workspace>/.cybernetics/relay-log.jsonl` 追加一条 JSON(`{ts, root, path, outcome, elapsedMs}`)。该文件是验收时的**落盘事实**,无需宿主 stdout 或浏览器 console 即可直接读取。`path` 取值:`native-panel`(顶层原生允许一次/拒绝面板)、`question-card`(可见的回退卡片)、`next-passthrough`(无活顶层 agent → 原生链)。若子代理快速失败且审计为 `decided unavailable` 而 `relay-log.jsonl` **一条记录都没有**,说明请求根本没到 relay(先确认宿主确实重载了插件构建);若出现 `path=native-panel outcome=unavailable` 后跟 `path=question-card` 两条,说明 frame 离开了宿主但浏览器没有认领——此时用户仍能看到问题卡片。另注:问题卡片回退通过 `ctx.get` 解析宿主的 `userQuestions` 服务(免 inject 的反射式读取,与 `dsh-plan-mode` 同法);旧构建用普通属性访问解析,而 cordis 对未 inject 的服务属性访问会直接抛错——v0.8.8 真机失败(「卡片从未出现、12ms 内终态 unavailable」)正是这个原因。
|
|
65
|
+
|
|
66
|
+
设 `gateApprovalRelay: false` 可回到严格确定性拒绝的立场(CI/无人值守部署)。该字段是 hardGate 伴随项,属**非 volatile / 配置文件维护**字段:请直接在 profile 配置文档中维护,保存触发插件重载。
|
|
67
|
+
|
|
68
|
+
宿主侧证据(文件:行号)、被否决的方案与复现方式记录在 [docs/approval-relay-investigation.md](docs/approval-relay-investigation.md);`test/relay-host-probe.mjs` 用真机 cordis waterfall + 真机 `ApprovalService` + 本插件 `apply()` 做回归探针。
|
|
48
69
|
|
|
49
70
|
### append-only goals 与 revise 决策树
|
|
50
71
|
|
|
@@ -127,7 +148,8 @@ Web 编辑器「插件」页的本插件卡片上直接配置——v0.8.1 起卡
|
|
|
127
148
|
- **task/verify/triage agent 模型**(`taskAgentModel` / `verifyAgentModel` / `triageAgentModel`,volatile):下拉加持的文本框——从实时系统模型列表选择(显示 `providerName · provider/model`)或手工输入「provider/model」/裸模型名,回车或移开焦点即保存,留空继承 session agent(triage = 收敛决策角色,仅 full 轮启用)。配置值不在实时模型列表中时显示不可用警告(运行时回退到 session agent 模型)并附一键清除;
|
|
128
149
|
- **递归深度 / 并行上限**(`roundsPerSubgoal` 整数 ≥1 / `maxConcurrentRounds` 整数 ≥0,volatile):数字框,回车或失焦保存,留空恢复默认(`unset` 写入);
|
|
129
150
|
- **roles 高级配置**(`roles`,非 volatile):profile 配置 yaml 直接维护——见下方小节;
|
|
130
|
-
-
|
|
151
|
+
- **硬编排门**(`hardGate`,v0.8.10 起 volatile):卡片布尔开关——装配期从顶层 agent 摘除写/执行工具,所有变更经 goal 循环完成;保存即时生效(门禁监听热装卸),无需重启;
|
|
152
|
+
- **其余门字段**(`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`,非 volatile):不进入卡片,请在 profile 配置文档中维护(保存触发插件重载)。`gateMode` 无默认值:未设置 + `hardGate: true` 按 `allowlist` 运行;显式 `denylist` 会被尊重,并带有上文记录的残留风险。
|
|
131
153
|
|
|
132
154
|
### roles 高级配置(profile yaml,agent 指导)
|
|
133
155
|
|
|
@@ -186,7 +208,7 @@ session agent 在会话内通过 `cybernetics` 工具(`toolName` 可配置)
|
|
|
186
208
|
## 已知限制
|
|
187
209
|
|
|
188
210
|
1. **settings 写入的宿主缺陷**:如「插件页配置」一节所述,该运行时上卡片保存可能被宿主在落盘前拒绝(上游 [#12045](https://github.com/deepseek-ai/dsh/discussions/12045));workaround:重启宿主 + 强刷浏览器,或手工 patch 行 + 重启。
|
|
189
|
-
2. **volatile 与非 volatile 字段的保存语义差异**:volatile 字段(`allGoalsMode`、`taskAgentModel`、`verifyAgentModel`、`triageAgentModel`、`roundsPerSubgoal`、`maxConcurrentRounds`)保存即时生效、不 remount;非 volatile 字段(v0.8.4 起的 `roles`、`
|
|
211
|
+
2. **volatile 与非 volatile 字段的保存语义差异**:volatile 字段(`allGoalsMode`、v0.8.10 起的 `hardGate`、`taskAgentModel`、`verifyAgentModel`、`triageAgentModel`、`roundsPerSubgoal`、`maxConcurrentRounds`)保存即时生效、不 remount;非 volatile 字段(v0.8.4 起的 `roles`、`gateMode`、`gatedTools`、`gateAllowlist*`、`gateApprovalRelay`、`toolName`、`implicitTrigger`)保存触发插件行 remount,需宿主重载完成。且宿主设置表单只服务也只接受 volatile 路径——运行中宿主的 schema 过旧时,volatile 写也会被拒(重启即愈)。
|
|
190
212
|
3. **`link:` 安装注意事项**:客户端 bundle 的注册 id 必须等于 package.json 的 `name`(`@mars.liu/dsh-cybernetics`);loader 行 id 可用短名 `dsh-cybernetics`。插件需挂在本 profile 的**顶层** loader entries(客户端扫描只枚举顶层行);`src/` 改动需宿主重载、`lib/` 改动需重启宿主 + 刷新浏览器。
|
|
191
213
|
4. 其他协议边界(硬门在装配层不在执行层、dsh-goal 焦点槽不随 superseded 迁移等)见 [docs/protocol.md](docs/protocol.md) 各「已知边界」小节。
|
|
192
214
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@
|
|
|
4
4
|
> 当下的功能与使用方式以 [README.md](../README.md) 为准;协议细节见 [protocol.md](protocol.md)。
|
|
5
5
|
> 约定:`src/` 改动需宿主重载生效;`lib/client.js` 改动需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器;`workflows/round.js` 改动热生效。
|
|
6
6
|
|
|
7
|
+
## v0.8.10(2026-11-05):hardGate 上设置卡片,volatile 热切换
|
|
8
|
+
|
|
9
|
+
**需求**:用户要在插件页配置卡片上直接开关硬编排门(像 `allGoalsMode` 一样保存即时生效,无需重启)。此前 `hardGate` 是非 volatile 字段,宿主设置表单只服务 volatile 路径,GUI 完全写不了它。
|
|
10
|
+
|
|
11
|
+
- **schema(`src/index.ts`)**:`hardGate` 加 `.volatile()`(保留 `default(true)`),description 更新为「设置卡片可开关,保存后即时生效(热装卸门禁监听),无需重启宿主」。`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay` 保持非 volatile 不动。
|
|
12
|
+
- **apply() 门禁接线可装卸**:装配 waterfall 摘工具 + `tools/pre-execute` 分发拒绝 + `gateApprovalRelay` 透传(hardGate 伴随项)提取为 `installGate()`(内部 `gateDisposers`,每次调用先卸旧装再按需安装,幂等)。`hardGate` 改为按使用时读取的函数(`readConfigField` 解 volatile live reference;普通配置缺省仍为不装门,与旧语义一致,schema 默认 true 经 live ref 到达运行时)。监听 `loader/volatile-update`:翻转时先 `registerGuidanceSection()`(HARD GATE 提示段随开关重推导)再 `installGate()`——与 `registerGuidanceSection` 同一重建模式。热关→开按当刻 `resolveGateMode()` 解析结果接线(显式 gateMode 优先,否则结构性 allowlist)。插件卸载 disposer 链不变。
|
|
13
|
+
- **client 卡片(`lib/client.js`)**:`SETTINGS_FIELDS` 首位新增 `{ field: 'hardGate', kind: 'boolean' }`;`fieldRow` 新增 boolean 分支(`booleanRow`,与头部 allGoalsMode 开关同款 switch 控件,走同一 `writeField` 写路径)。双语 locale 补 `settings.hardGate` / `settings.hardGateHint`:开启后顶层 agent 写工具被摘除、所有变更经 goal 循环;即时生效,无需重启。
|
|
14
|
+
- **测试(`test/v03-offline.mjs`,增至 72 项)**:新增用例 71——(a) 初始 `hardGate=false` 经 live ref 热切 true:装配中 bash 被摘、pre-execute 拒绝、guidance 含 HARD GATE;(b) true→false:bash 恢复、assemble/pre-execute 监听已 dispose、guidance 摘除段;(c) 反复切换幂等(始终恰好一组活监听,末态 hardGate=true 仍拒绝);(d) schema 断言 hardGate volatile、其余门字段非 volatile。同步更新既有断言:用例 51/56/57 中 hardGate 从「非 volatile 负向对照」改为 volatile 正向(负向对照换 `gateMode`),volatileForm 服务字段六→七。既有全部用例通过。
|
|
15
|
+
- **文档**:README / README.zh-CN「硬门」小节与「插件页配置」「已知限制」更新——hardGate 从「配置文件维护/重启生效」改为「GUI 卡片开关、volatile 即时生效」;注明首次部署需重启宿主一次加载新代码,此后开关热生效。
|
|
16
|
+
- **生效手段**:`src/index.ts` 改动——重启宿主一次加载新代码,此后开关热生效;`lib/client.js` 改动——重启宿主 + 刷新浏览器(本仓库 `lib/` 即源码)。
|
|
17
|
+
|
|
18
|
+
## v0.8.7(2026-11-04):模型下拉 focus-parking(lib/client.js,仅客户端)
|
|
19
|
+
|
|
20
|
+
**问题**:原生 datalist 按输入框当前文本过滤选项——已配置模型的字段聚焦后下拉只剩当前那一个模型,用户看不到完整目录,容易误以为没有别的选择。改进方向不是自绘下拉,而是顺应原生 datalist 的过滤语义。
|
|
21
|
+
|
|
22
|
+
- **聚焦占位(focus parking)**:model 字段(task/verify/session/triage 共四个)聚焦或点击时,把已有文本收进 `placeholder`(value 收进占位符、输入框清空),datalist 因此展开全部系统模型;焦点离开(blur/Enter/Escape)且未编辑时恢复占位文本并**不写任何东西**——只是看看列表永远不会把已配置模型置空。
|
|
23
|
+
- **编辑语义保持**:占位状态下输入或选中选项即视为真实编辑,走 v0.8.1 的常规提交路径(blank = unset,回车提交);`hadDraft` 区分占位文本是未提交草稿(保留草稿)还是快照值(恢复即清草稿)。Escape 关闭列表后值已恢复但字段仍持有焦点,此时 `onClick` 也挂 parkCurrent,保证再点一次仍能展开全列表(不再触发 onFocus)。
|
|
24
|
+
- **提示文案**(双语 `settings.modelFieldHint` / `settings.triageModelHint`)同步改写:点开输入框即可看到全部可选模型,已有值先收进占位符、离开且未改动不会保存。
|
|
25
|
+
- **生效手段**:`lib/client.js` 改动,重启宿主 + 刷新浏览器后生效。`lib/` 是源码同目录(构建产物即源),无需额外构建。
|
|
26
|
+
|
|
27
|
+
## v0.8.9(2026-11-04):relay 卡片回退真因修复 + relay-log 落盘
|
|
28
|
+
|
|
29
|
+
- **根因修复**(src/index.ts):问题卡片回退改用 `ctx.get('userQuestions')` 解析宿主服务(免 inject 的反射式读取,与 dsh-plan-mode 同法)。旧构建用普通属性访问 `ctx.userQuestions`——cordis 对未 inject 服务的属性访问直接抛 "cannot get property without inject"(cordis/lib/index.js:88-96),try/catch 吞掉后立即返回终态 `unavailable`,这就是 v0.8.8 真机「原生面板 unavailable 后卡片从未出现、12ms 内终结」的直接原因。
|
|
30
|
+
- **relay-log 落盘**:每次 relay 决策在 host logger 行之外追加一条 JSON 到 `<workspace>/.cybernetics/relay-log.jsonl`(`{ts, root, path, outcome, elapsedMs}`,fire-and-forget 不影响审批链路),验收无需宿主 stdout / 浏览器 console 即可读取 path/outcome。
|
|
31
|
+
- **测试**:test/relay-host-probe.mjs 新增场景 5——cordis 忠实 stub(属性访问抛错、仅 ctx.get 可达)下卡片回退依然可达用户 + relay-log.jsonl 落盘断言;test/v03-offline.mjs 全部通过。
|
|
32
|
+
- **文档**:README 双语排障指引更新(relay-log 读取方法与「零记录 = 请求没到 relay」判据)。
|
|
33
|
+
|
|
34
|
+
## v0.8.6(2026-11-04):hardGate 门禁语义默认安全 + guidance 与模式一致(P1)
|
|
35
|
+
|
|
36
|
+
**问题(cyb-3e1ab616 诊断核实)**:`gateMode` 在 schema 里 `.default('denylist')`,而 `DEFAULT_GATED_TOOLS` 只有 `['bash','edit','write']`——任何只写 `hardGate: true` 的部署都得到一道半门:`bsk_*`、`taskboard_create`、`schedule_create`、`farm_start`、`uno_*`、`ppt_create`、`config_restore` 等写工具全部存活;另一会话因此退化成 810 次 `bsk_session` start/stop 死循环。同时 `HARD_GATE_GUIDANCE` 在 denylist 模式下仍宣称“所有改世界的工具结构性不可用”,属假陈述。
|
|
37
|
+
|
|
38
|
+
- **`src/index.ts` schema**:`gateMode` 去掉 `.default('denylist')`,改为无默认的可选字段(schemastery 对未设置值保留 `undefined`,离线实测 `validate({})` 结果为 `{hardGate:true}`,不含 gateMode)。**用户沉默不再被默认值抹掉**,apply() 才做推导。
|
|
39
|
+
- **`src/index.ts` apply()**:新增 `resolveGateMode(config, hardGate)`——显式 `'denylist'`/`'allowlist'` 原样尊重;未设置时 `hardGate ? 'allowlist' : 'denylist'`。`hardGate=false` 时不装任何门禁监听,行为与旧版完全一致。装配过滤与 `tools/pre-execute` 分发拒绝共用同一个 `allowlistMode`。
|
|
40
|
+
- **`HARD_GATE_GUIDANCE` 按模式生成**:拆成 `HARD_GATE_GUIDANCE_ALLOWLIST`(保留“所有改世界的工具 STRUCTURALLY unavailable”的强表述)与 `HARD_GATE_GUIDANCE_DENYLIST`(如实说明“只摘 gatedTools,bsk_*/taskboard_*/schedule_*/farm_*/uno_*/ppt_*/config_restore 等仍可调用,这是 PARTIAL 而非结构性门”,并明确禁止“换一个写工具绕过编排流程”)。`registerGuidanceSection()` 在生成时按解析后的模式拼接;hardGate/gateMode 非 volatile,重载后重新生成即可(沿用 volatile 重建模式,不受影响)。
|
|
41
|
+
- **文档**:README / README.zh-CN「硬门」小节重写——默认语义(hardGate 伴生 allowlist)、`gateMode` 无默认值、显式 denylist 的残留风险与仍可用工具清单(bsk_*/taskboard_*/schedule_*/farm_*/uno_*/ppt_*/config_restore/subagent 等)、以及“denylist 永远不可能完备(集合随插件增长)”。
|
|
42
|
+
- **测试**:离线测试增至 **71 项**——新增 16b 覆盖三种组合:(a) `hardGate:true` 无 `gateMode` → allowlist 生效(长尾写工具在装配期移除且分发期 deny);(b) `hardGate:true + gateMode:'denylist'` → 仅 bash/edit/write 被摘,farm_start 仍在分发层放行(尊重显式配置、不静默升级);(c) `hardGate:false` → 无门禁监听、无 HARD GATE guidance。用例同时断言双模式 guidance 文案:allowlist 含 `(allowlist mode)` + `STRUCTURALLY unavailable`;denylist 含 `(denylist mode)` + `gatedTools` + `PARTIAL, not structural`,且**不含** `STRUCTURALLY unavailable` 假陈述;另加 schema 断言 `validate({})` 的 `gateMode` 为 `undefined`、显式 denylist 经校验仍为 denylist。既有 70 项全过。
|
|
43
|
+
- **生效手段**:`src/` 改动,**重启 dsh web(宿主重载)后生效**。profile 端 `/Users/mars/.dsh/profiles/web/node_modules/dsh-cybernetics` 是指向源码目录的软链,无需复制;`cordis.patch.yml` 未改动(其中已显式 `gateMode: allowlist`)。
|
|
44
|
+
|
|
45
|
+
## v0.8.5(2026-11-04):hardGate 子代理审批透传(`gateApprovalRelay`,最后一跳修复)
|
|
46
|
+
|
|
47
|
+
**问题**:宿主在委派边界把子代理审批策略钉死为 `never`(`dsh-subagent/lib/index.js:413` `captureDelegatedPolicyOverrides`;`dsh-user-approval/lib/index.js:175` 在 waterfall 之前直接 `rejected`),task agent 的沙箱越权请求到不了用户。**第一跳**:`agent/created` 时给子代理 session 追加 `approval/policy {policy:'ask'}`(真机证据:本任务子代理 session 日志 seq 2 为 `never`/`source:delegation`,seq 3 为插件追加的 `ask`,相隔 52 ms)。**最后一跳(本轮修复)**:原实现把 `approval/request` answerer 注册在内层、先 `next()` 给原生链一个限时窗口——但宿主 `api-remotes` 在启动时(早于所有用户插件)注册了 `approval/request` 转发器(`dsh-api-remotes/lib/index.js:21-28,147,233`),浏览器审批 answerer(`dsh-client-ui-approval/lib/client.js:284`)claim 子代理请求后等待一张"用户看不见的子会话面板"且永不 `next()`;cordis waterfall 由外向内执行(`cordis/lib/index.js:317-325`),内层 relay 因此**永远轮不到执行**,子代理只能挂死。改为 `{ prepend: true }` 最外层接管:沿 `parentSession` 链解析顶层活祖先,用宿主自己的 `ApprovalService.request` 在**用户正在看的会话**上重新发起请求(原生"允许一次/拒绝"面板 + 该 session 的 `approval/asked`/`approval/decided` 审计对,不转发子代理 callId),顶层回合已关闭时回退同一 agent 的 `ask_user_question` 卡片,无活顶层 agent 再回落原生链。旧的 `gateApprovalRelayTimeoutMs` 竞态窗口字段删除(relay 不再与原生链赛跑,等待语义与普通审批一致,由调用方的 signal/超时取消)。**验证**:离线测试 70 重写为最外层注册 + child→mid→root 攀爬 + 原生面板/问题/原生链三层回退 + 排序回归与负向对照;新增 `test/relay-host-probe.mjs` 用真机 cordis + 真机 `ApprovalService` + 真实插件 `apply()` 端到端跑通(子代理请求只落到顶层会话面板、隐藏子会话面板 0 次 claim;顶层拒绝/回合关闭回退均验证)。**生效手段**:`src/` 改动,宿主重载后生效(运行中的宿主仍加载旧构建,需重载/重启才能实测 GUI 弹窗)。
|
|
48
|
+
|
|
7
49
|
## v0.8.4(2026-11-04):配置卡片移除 roles JSON 编辑项(roles 改经 profile yaml 高级配置)
|
|
8
50
|
|
|
9
51
|
**决策理由(记录在案)**:v0.8.1 起 roles 以 JSON 文本域 +「应用」按钮的形式上卡片,用户实测反馈:在 GUI 卡片里手工编辑一个 JSON 对象(`role → {provider, model}`)对用户不友善——JSON 语法错误校验、转义、role 名拼写全暴露给用户。roles 能力本身保留在 schema/运行时(命名角色进阶场景仍可用),但 GUI 不再暴露;确需配置时由 agent 指导用户直接编辑 profile 配置 yaml(用户 patch 行),README/README.zh-CN 各新增「roles 高级配置」小节记录字段位置示例与重载要求。
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 子代理审批透传(gateApprovalRelay)调查与设计 — 2026-11-04 v0.8.5
|
|
2
|
+
|
|
3
|
+
> 目标:hardGate 模式下,委派子代理(workflow task/verify/triage、teammate)的沙箱越权请求
|
|
4
|
+
> 必须能真正弹到用户 GUI,而不是被宿主/客户端的既有应答链静默吞掉。
|
|
5
|
+
> 本文记录代码级证据(宿主版本 0.2.1-rc.3,路径 `~/.dsh/profiles/web/node_modules/@deepseek-ai/`)、
|
|
6
|
+
> 根因判定、取舍与验证方式。
|
|
7
|
+
|
|
8
|
+
## 1. 现象与事实
|
|
9
|
+
|
|
10
|
+
- 子代理访问会话工作区之外的路径(如 `~/.dsh/profiles/web/cordis.patch.yml`)时,工具层
|
|
11
|
+
`sandbox_permissions` 升级会走 `ctx.approval.request(...)`(`dsh-tools/lib/index.js:3551`、
|
|
12
|
+
`dsh-sandbox/lib/index.js:99-120`),但用户看不到任何提示,子代理只能失败/回退人工。
|
|
13
|
+
- 真机证据(本任务子代理 session `18168d3c` 的 `session.v4.jsonl.zstd`):seq 2 是
|
|
14
|
+
`approval/policy {policy:'never', source:'delegation'}`,seq 3 是插件 `agent/created`
|
|
15
|
+
监听追加的 `{policy:'ask'}`(相隔 52 ms)——**第一跳(解除钉死)在真实宿主上有效**;
|
|
16
|
+
失败点在第二跳(请求送达用户)。
|
|
17
|
+
|
|
18
|
+
## 2. 宿主机制(代码证据)
|
|
19
|
+
|
|
20
|
+
| 环节 | 证据 |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| 委派时审批策略被**钉死为 `never`** | `dsh-subagent/lib/index.js:413` `approvalPolicy: parent.ctx.get("approval") === undefined ? undefined : "never"`;`dsh-subagent/lib/types/child-agent.js:185,202`;`appendDelegatedPolicyOverrides` 把它写成 `approval/policy {source:'delegation'}`(`:430-432`) |
|
|
23
|
+
| `never` 在**任何 answerer 之前**确定性拒绝 | `dsh-user-approval/lib/index.js:172-176`:`decide()` 先 `if (effectivePolicy === 'never') return 'rejected'`,之后才 `waterfall('approval/request', ...)` |
|
|
24
|
+
| 审批请求必须落在**打开的回合**内 | `dsh-user-approval/lib/index.js:130`(`hasOpenTurn` 检查,audit 对必须 turn-enclosed) |
|
|
25
|
+
| 宿主把 `approval/request` 转发给浏览器 | `dsh-api-remotes/lib/index.js:21-28`(`API_REMOTE_FORWARDED_EVENTS` 含 `approval/request` waterfall)、`:147`(boot 时 `registerRemoteEvents`)、`:233-252`(`forwardWaterfall`:推入 client 队列并等待结果,client 不 delegate 就不 `next()`);`dsh-api-gateway/lib/index.js:1105` 仅投递给 `answers` 的 owner client |
|
|
26
|
+
| 浏览器审批 answerer 的行为 | `dsh-client-ui-approval/lib/client.js:284-320`:`sessions.scopeOf(owner)` → 构造 `PendingApproval` → `uiSession.registerPendingInteraction` → **等待用户**;面板只渲染"当前打开会话"的 pendingInteraction(`dsh-client-ui-conversation/lib/client.js:17814`) |
|
|
27
|
+
| waterfall 由外向内执行 | `cordis/lib/index.js:317-325`:`dispatch()` 按注册顺序取监听器,`waterfall` 逐个 `shift`,外层不调用 `next()` 则内层永不执行 |
|
|
28
|
+
|
|
29
|
+
**结论(宿主没有现成的"审批委托"机制)**:`captureDelegatedPolicyOverrides` 只有
|
|
30
|
+
`'never'` 一种委派策略,没有 `inherit`/`approval: 'parent'` 之类的开关;子代理请求也不会自动改投父会话。
|
|
31
|
+
因此只能在插件侧中转。
|
|
32
|
+
|
|
33
|
+
## 3. 根因(最后一跳)
|
|
34
|
+
|
|
35
|
+
- 宿主 `api-remotes` 在 **boot 时**(bundle 顺序早于所有用户插件:
|
|
36
|
+
`dsh-web-app/cordis.patch.yml:248-249`,profile `package.json` bundles 中 `dsh-cybernetics` 排在其后)
|
|
37
|
+
就注册了 `approval/request` 监听器;cybernetics 的 relay 注册得更晚 ⇒ 在 waterfall 中处于**内层**。
|
|
38
|
+
- 子代理请求先到 `api-remotes` → 推给浏览器 → 浏览器 answerer **claim 子会话请求**并等待一张
|
|
39
|
+
"用户根本看不见的 per-session 面板"(当前打开的是父会话),且**永不 `next()`**。
|
|
40
|
+
- 于是内层 relay 从来不会被执行:上一版"限时 `next()` + 超时转 userQuestions"的窗口逻辑是**死代码**,
|
|
41
|
+
子代理只会挂在被 claim 的请求上。用户看到的现象就是"没有弹窗、任务失败"。
|
|
42
|
+
|
|
43
|
+
最小复现(真实 cordis,`new Context()`):
|
|
44
|
+
外层监听器返回一个永不 settle 的 Promise、内层返回 `'allowed-once'`,`waterfall` 的 Promise 永不 settle;
|
|
45
|
+
给内层加 `{ prepend: true }` 后内层成为外层,立即返回 `'allowed-once'`(见 `test/relay-host-probe.mjs` 的
|
|
46
|
+
排序断言与 `test/v03-offline.mjs` 检查 70 的负向对照)。
|
|
47
|
+
|
|
48
|
+
## 4. 设计取舍
|
|
49
|
+
|
|
50
|
+
**采用:最外层 relay + 在顶层 session agent 上复用宿主的原生审批通道。**
|
|
51
|
+
|
|
52
|
+
- `ctx.on('approval/request', handler, { prepend: true })` —— 必须先于 `api-remotes` 拿到请求;
|
|
53
|
+
这是唯一不依赖插件加载顺序的可靠位置。
|
|
54
|
+
- 只接管**委派子代理**(`session.header.origin === 'subagent'`;fork 有 `parentSession` 但独立成根,
|
|
55
|
+
用 `delegationDepth > 0` 兜底区分)。
|
|
56
|
+
- 沿持久 `parentSession` 链向上解析**顶层活祖先**(`ctx.agents.get(parentSession)`,
|
|
57
|
+
与 `dsh-experimental-agent-team/lib/index.js:873-876` 的同款解析一致),把请求用
|
|
58
|
+
`ctx.get('approval').request({ agent: <顶层>, toolName, reason, displayReason, signal })`
|
|
59
|
+
在该 agent 的会话上**重新发起**:
|
|
60
|
+
- 面板就是用户熟悉的原生 "Allow once / Reject"(`dsh-client-ui-approval/lib/client.js:38-120`),
|
|
61
|
+
渲染在用户正在看的会话里;该会话同时得到 `approval/asked` + `approval/decided` 审计对。
|
|
62
|
+
- **不转发子代理的 `callId`**:那是子会话里的 tool call,不能与父会话 transcript 关联。
|
|
63
|
+
- `displayReason`(本地化)写明 `Sub-agent <id> requests privileged execution of <tool>`,
|
|
64
|
+
审计 `reason` 带 `[sub-agent <id>]` 前缀。
|
|
65
|
+
- 回退链:顶层回合已关闭(`ApprovalService.request` 抛 open-turn 错)→ 同一 agent 的
|
|
66
|
+
`ask_user_question` 卡片(`ctx.userQuestions.ask`,不要求 open turn)→ 没有活顶层 agent 则 `next()`
|
|
67
|
+
交给原生链(fail-closed 语义不变)。
|
|
68
|
+
- 失败语义:转发抛错 → 显式 `rejected`;子代理 signal 已中止 → `cancelled`;
|
|
69
|
+
顶层审批返回 `unavailable`(无客户端)→ `unavailable`。绝不静默成功。
|
|
70
|
+
- **删除** `gateApprovalRelayTimeoutMs`:relay 不再与原生链赛跑,"等用户"就是普通审批的语义;
|
|
71
|
+
调用方的 `signal`/工具超时负责取消。
|
|
72
|
+
|
|
73
|
+
**否决的方案**:
|
|
74
|
+
1. *保留限时窗口、抢在原生链之后* —— 内层永远轮不到,且 15s 空窗期内用户什么都看不到。
|
|
75
|
+
2. *只用 `ask_user_question` 转发* —— 能用,但问题卡片不是审批面板(措辞/按键/审计都不对),
|
|
76
|
+
且不落在审批审计链上;作为"顶层回合已关闭"的回退保留。
|
|
77
|
+
3. *修改宿主 `api-remotes` 或客户端 answerer* —— 越出插件边界,且会改变所有插件的审批语义。
|
|
78
|
+
4. *把子代理策略改成 `never` 之外的持久继承* —— 宿主没有该能力,且子代理自身仍是"看不见的面板"。
|
|
79
|
+
|
|
80
|
+
## 5. 验证
|
|
81
|
+
|
|
82
|
+
- `node --experimental-strip-types test/v03-offline.mjs` → **ALL 71 CHECKS PASSED**(检查 70 重写:
|
|
83
|
+
最外层注册、child→mid→root 攀爬、原生面板/问题/原生链三层回退、无 callId、
|
|
84
|
+
排序回归 + 内层负向对照、opt-out、schema/README)。
|
|
85
|
+
- `node test/relay-host-probe.mjs` → 用**真机 cordis + 真机 `ApprovalService` + 真实插件 `apply()`**
|
|
86
|
+
跑通:子代理 escalation 只落到顶层会话的审批通道(模拟浏览器只 claim 顶层、对子会话永不 delegate),
|
|
87
|
+
顶层会话留下 `approval/asked`/`approval/decided`;顶层回合关闭时回退问题通道;顶层拒绝显式回传。
|
|
88
|
+
- 真机证据:本任务子代理 session 的 `approval/policy` seq 2/3(钉死 → ask),父会话在子代理运行期间
|
|
89
|
+
处于 open turn(`turn/start` 无对应 `turn/end`)。
|
|
90
|
+
|
|
91
|
+
## 6. 已知边界
|
|
92
|
+
|
|
93
|
+
- **运行中的宿主仍加载旧构建**:`src/` 改动需宿主重载/重启后才生效;本轮无法在不打断当前
|
|
94
|
+
goal round 的前提下重启宿主,因此 GUI 实测弹窗需在重载后复验一次(发一条越权子代理任务,
|
|
95
|
+
看父会话是否出现 "Allow once / Reject" 面板)。
|
|
96
|
+
- 子代理**自己的会话面板不再被使用**(请求一律改投顶层会话)——这是有意的:那张面板正是"看不见的
|
|
97
|
+
最后一跳"的成因;需要时仍可打开子会话,但那不是本次的交付路径。
|
|
98
|
+
- relay 只在 `hardGate: true` 时安装(`gateApprovalRelay` 是 hardGate 伴随项)。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 诊断报告:session-9e7abdf0 绕过 goal 模式的根因(2026-11-04)
|
|
2
|
+
|
|
3
|
+
## 结论(根因判定)
|
|
4
|
+
|
|
5
|
+
**主根因 = d(其他):硬门禁对该会话实际生效,但生效的是 denylist 模式(只摘 bash/edit/write 三个工具),配置中的 `gateMode: allowlist` 从未应用到 web profile。** bsk_* 浏览器自动化工具全部保留,所有"写操作"都从这些门禁盲区工具走掉了。a/b/c 三个假设均被证据排除或弱化。
|
|
6
|
+
|
|
7
|
+
## 证据链
|
|
8
|
+
|
|
9
|
+
### 1. 门禁在作用域内(排除假设 a:spawn 链不同)
|
|
10
|
+
该会话每轮 request/header 中被广告的工具数与内容(session.v4.jsonl.zstd 解压到 /tmp/s9e7.jsonl):
|
|
11
|
+
|
|
12
|
+
| seq | 时间 | 工具数 | bash | write | bsk_session | 说明 |
|
|
13
|
+
|-----|------|--------|------|-------|-------------|------|
|
|
14
|
+
| 13 | 15:19 | 124 | 有 | 有 | 有 | 门禁配置前 |
|
|
15
|
+
| 495 | 15:53 | 124 | 有 | 有 | 有 | 门禁配置前 |
|
|
16
|
+
| 1317| 17:25 | 124 | 有 | 有 | 有 | 17:28 写入 hardGate 前 |
|
|
17
|
+
| 1346| 17:36 | **121** | **无** | **无** | 有 | 门禁生效:恰好只少了 bash/edit/write |
|
|
18
|
+
| 1360| 17:37 | 121 | 无 | 无 | 有 | |
|
|
19
|
+
| 5102| 19:50 | 121 | 无 | 无 | 有 | **19:47 新宿主(pid 90659)重启后首个 header**,摘除延续 |
|
|
20
|
+
| 5266| 19:53 | 121 | 无 | 无 | 有 | 切到 kimi/k3 后同样摘除 |
|
|
21
|
+
|
|
22
|
+
121 = 124 − {bash, edit, write},与 `DEFAULT_GATED_TOOLS = ['bash','edit','write']`(src/index.ts:494)精确吻合。
|
|
23
|
+
→ 会话被视为顶层 agent(assembly filter 应用)、宿主重启后装配已刷新(排除假设 b 装配缓存问题)。全程日志中 **0 条** "cybernetics hard gate: tool … not available" 拒绝(grep 计 0)——因为模型从未调用被摘的三个工具,其余 118 个工具全 open。
|
|
24
|
+
|
|
25
|
+
### 2. 生效配置丢失 allowlist(根因所在)
|
|
26
|
+
- 插件仓库自带的 `/Users/mars/jobs/dsh-cybernetics/cordis.patch.yml` 配置了 `gateMode: allowlist` + `gateAllowlistExtra: [spawn_teammate, send_message, interrupt_agent]`。
|
|
27
|
+
- 但 web profile 的用户 patch 行 `/Users/mars/.dsh/profiles/web/cordis.patch.yml:441-448` 的 config **只有 5 个键**:taskAgentModel / verifyAgentModel / allGoalsMode / triageAgentModel / hardGate —— **没有 gateMode / gateAllowlist / gateAllowlistExtra**。
|
|
28
|
+
- 同 id 的 patch 行是**整体替换 config 而非合并**(cordis-plugin-loader/lib/index.js:78-80 `this.data = config`,按 id 重建 map),profile 层的行覆盖后 allowlist 配置消失。
|
|
29
|
+
- 运行时判定:src/index.ts:594 `const allowlistMode = config.gateMode === 'allowlist'` → false → denylist 分支;src/index.ts:601 未配置 gatedTools → 落到 DEFAULT_GATED_TOOLS。工具表证据(121 枚)与该路径完全一致。
|
|
30
|
+
|
|
31
|
+
### 3. allGoalsMode 指引正常下发(排除装配路径问题)
|
|
32
|
+
system prompt 全量重播(system/message 事件 seq 7 / 1343 / 5264)均包含 cybernetics section、ALL-GOALS 指引与 HARD_GATE_GUIDANCE 文本(seq 5264 解码验证:has cybernetics=True / ALL-GOALS=True / hard gate=True)。19:53 换 kimi/k3 后的 5264 号 system prompt 也带。模型只是无视指引。
|
|
33
|
+
|
|
34
|
+
### 4. 模型回退假设(c)不成立,但确有一次 NO_ADAPTER
|
|
35
|
+
- request/header 全部 8 次中 7 次 provider=zai-coding-cn/glm-5.3-flash;19:53 起才切 kimi/k3。不存在反复回退。
|
|
36
|
+
- 唯一失败:seq 1362,17:37:10,turn 23 step 1,`finish reason error: "no adapter registered for provider \"zai-coding-cn\""(NO_ADAPTER)`——GLM off 键窗口的一次性失败,此后同一模型继续正常出 token。
|
|
37
|
+
- ~/.dsh/dsh-usage/usage-ledger.json 存在但按 days 组织、无 session 维度记录(按 9e7abdf0 检索 0 命中);provider-snapshots.json 显示 zai-coding-cn pro 档 5h 窗口 98% 用量。死循环与模型不可用无因果。
|
|
38
|
+
|
|
39
|
+
### 5. "写操作"的真实通道
|
|
40
|
+
18:26 重启后(cut=18:26:00),该会话工具调用统计:bsk_session 772 / browser_navigate 3 / bsk_page 3,**bash/write/edit 均为 0**(门禁摘除生效)。建频道、API 发消息、PDF/pptx 生成(bash 41、write 10、uno_create 3)全部发生在 17:36 门禁生效**之前**。重启后唯一的大量行为就是 bsk_session start/stop 死循环(18:42–19:53,每次约 5 秒一个 start+stop,共 772 次调用)——这是门禁盲区工具上的可靠性事故,而非 goal 绕过写文件。
|
|
41
|
+
|
|
42
|
+
## 修复建议(未执行任何写操作)
|
|
43
|
+
1. **主修复**:把 `gateMode: allowlist`(必要时连同 gateAllowlistExtra)补进 `/Users/mars/.dsh/profiles/web/cordis.patch.yml:441` 的 dsh-cybernetics config 块,重启宿主。验证方式:新回合 request/header 工具数应骤降到 ~45(allowlist+cybernetics),且 bsk_session 不再出现在广告列表(allowlist 里没有它)。
|
|
44
|
+
2. **决策项**:bsk_* 浏览器工具是否进 allowlist。读类(browser_tabs/snapshot/console/network/screenshot/navigate/scroll)已在 DEFAULT_GATE_ALLOWLIST;bsk_session/bsk_interact/bsk_page/bsk_assist/bsk_tabs 具备创建会话、点击、replay 等写能力,建议默认挡在门外,确有只读浏览需求时按最小集放行。
|
|
45
|
+
3. **防复发**:profile 用户 patch 行整体覆盖插件 config 是脚枪——要么在插件 README/面板提示"用户行必须携带完整门禁字段",要么把 loader 改为对非 volatile 字段深合并。另建议 GUI volatile 保存生成的用户行保留原 config 键而非重写。
|
|
46
|
+
4. **独立问题**:bsk_session 772 连环 start/stop 说明 repeat-tool-reminder / 循环熔断未起作用,值得单独排查(超出本轮)。
|
|
47
|
+
|
|
48
|
+
## 偏差说明
|
|
49
|
+
- 委托书称宿主 18:26 重启(pid 19488);磁盘 running/web.json 显示当前宿主 pid 90659、19:47:37 启动——期间又重启过一次,本诊断两条宿主实例的证据都已覆盖(1346 与 5102 分别对应两代宿主的摘除生效)。
|
|
50
|
+
- referenced 快照所称"重启后建频道/发消息/生成 PDF"实际发生在 17:36 门禁生效前;重启后并无被禁工具调用,只有门禁盲区的 bsk_* 循环。
|
package/lib/client.js
CHANGED
|
@@ -95,7 +95,7 @@ window.__ModuleLoader__.load({
|
|
|
95
95
|
// v0.8.3: the three model fields are dropdown-backed (datalist over the
|
|
96
96
|
// host's live model catalog, remote.session.modelCatalog) with free
|
|
97
97
|
// manual entry preserved — the datalist input accepts any text.
|
|
98
|
-
'settings.modelFieldHint': '可从下拉选择当前系统模型(显示 provider · provider/model
|
|
98
|
+
'settings.modelFieldHint': '可从下拉选择当前系统模型(显示 provider · provider/model);点开输入框(聚焦)即可看到全部可选模型——已有值会先收进占位符,离开且未改动不会保存。也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。回车或移开焦点即保存。',
|
|
99
99
|
// v0.8.3: configured value no longer in the live catalog = deleted
|
|
100
100
|
// subscription. Warn + one-click clear; NEVER auto-unset (the value
|
|
101
101
|
// may be valid again after resubscribing, and silent rewrites of
|
|
@@ -107,7 +107,7 @@ window.__ModuleLoader__.load({
|
|
|
107
107
|
// task/verify results and emits the round's convergence decision
|
|
108
108
|
// (met/continue/human-input/...); quick rounds never invoke it.
|
|
109
109
|
'settings.triageAgentModel': 'Triage agent 模型',
|
|
110
|
-
'settings.triageModelHint': '
|
|
110
|
+
'settings.triageModelHint': '可从下拉选择当前系统模型;点开输入框(聚焦)即可看到全部可选模型(已有值先收进占位符,离开且未改动不会保存)。也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。triage 角色读 task/verify 结果输出本轮收敛决策(met/continue/human-input 等),quick 轮不启用。回车或移开焦点即保存。',
|
|
111
111
|
'settings.roundsPerSubgoal': '子目标轮数上限',
|
|
112
112
|
'settings.roundsPerSubgoalHint': '同一子目标的最大连续轮数(整数 ≥1,缺省 3);留空恢复默认。回车或移开焦点即保存。',
|
|
113
113
|
'settings.maxConcurrentRounds': '并行轮数上限',
|
|
@@ -115,6 +115,9 @@ window.__ModuleLoader__.load({
|
|
|
115
115
|
// v0.8.4: roles 高级配置不再上卡片(手工编辑 JSON 对用户不友善)——
|
|
116
116
|
// 由 agent 指导用户直接编辑 profile 配置 yaml(见 README)。
|
|
117
117
|
'settings.invalidNumber': '请输入符合范围的整数,或留空恢复默认。',
|
|
118
|
+
// v0.8.10: hardGate 上卡片(volatile,热切换)。
|
|
119
|
+
'settings.hardGate': '硬编排门',
|
|
120
|
+
'settings.hardGateHint': '开启后,顶层 agent 的写/执行工具在装配期被摘除,所有变更必须经 cybernetics goal + task agent 委派完成。开关保存后即时生效(热装卸门禁监听),无需重启。',
|
|
118
121
|
};
|
|
119
122
|
const en = {
|
|
120
123
|
'view.goal': 'Goal',
|
|
@@ -169,17 +172,20 @@ window.__ModuleLoader__.load({
|
|
|
169
172
|
// v0.8.1: the remaining five volatile fields (see zh for the rationale).
|
|
170
173
|
'settings.taskAgentModel': 'Task agent model',
|
|
171
174
|
'settings.verifyAgentModel': 'Verify agent model',
|
|
172
|
-
'settings.modelFieldHint': 'Pick from the dropdown of currently available system models (shown as provider · provider/model)
|
|
175
|
+
'settings.modelFieldHint': 'Pick from the dropdown of currently available system models (shown as provider · provider/model); click into the input to see the whole list — an existing value is parked as the placeholder and restored on blur without saving. You can also type "provider/model" or a bare model name; empty = inherit the session agent. Enter or blur saves.',
|
|
173
176
|
'settings.modelUnavailable': 'This model is no longer in the system model list; at runtime the round falls back to the session agent model.',
|
|
174
177
|
'settings.modelClear': 'Clear',
|
|
175
178
|
// v0.8.2: the triage role's flat model field (see zh for the rationale).
|
|
176
179
|
'settings.triageAgentModel': 'Triage agent model',
|
|
177
|
-
'settings.triageModelHint': 'Pick from the dropdown of available system models
|
|
180
|
+
'settings.triageModelHint': 'Pick from the dropdown of available system models; click into the input to see the whole list (an existing value is parked as the placeholder and restored on blur without saving). You can also type "provider/model" or a bare model name; empty = inherit the session agent. The triage role reads the task/verify results and emits the round\'s convergence decision (met/continue/human-input/...); quick rounds never invoke it. Enter or blur saves.',
|
|
178
181
|
'settings.roundsPerSubgoal': 'Rounds per subgoal',
|
|
179
182
|
'settings.roundsPerSubgoalHint': 'Max consecutive rounds on one subgoal (integer ≥1, default 3); empty restores the default. Enter or blur saves.',
|
|
180
183
|
'settings.maxConcurrentRounds': 'Concurrent round cap',
|
|
181
184
|
'settings.maxConcurrentRoundsHint': 'Global cap on rounds in flight (integer ≥0, default 0 = unlimited); empty restores the default. Enter or blur saves.',
|
|
182
185
|
'settings.invalidNumber': 'Enter an integer in range, or leave blank to restore the default.',
|
|
186
|
+
// v0.8.10: hardGate joins the card (volatile, hot-toggled).
|
|
187
|
+
'settings.hardGate': 'Hard orchestration gate',
|
|
188
|
+
'settings.hardGateHint': 'When on, write/execution tools are stripped from the TOP-LEVEL agent at assembly time; every change must go through a cybernetics goal + task agent. Takes effect immediately on save (gate listeners are hot-installed/disposed); no restart needed.',
|
|
183
189
|
};
|
|
184
190
|
//#endregion
|
|
185
191
|
|
|
@@ -544,10 +550,16 @@ window.__ModuleLoader__.load({
|
|
|
544
550
|
// an `unset` op). v0.8.4: roles is no longer on the card — hand-editing
|
|
545
551
|
// a JSON object in the GUI is unfriendly; roles became non-volatile and
|
|
546
552
|
// is configured directly in the profile yaml (agent-guided, README).
|
|
547
|
-
// The card's field list is back to
|
|
548
|
-
//
|
|
553
|
+
// The card's field list is back to seven (v0.8.10):
|
|
554
|
+
// hardGate (boolean toggle) / allGoalsMode-toggle header +
|
|
555
|
+
// taskAgentModel / verifyAgentModel / triageAgentModel /
|
|
549
556
|
// roundsPerSubgoal / maxConcurrentRounds.
|
|
550
557
|
const SETTINGS_FIELDS = [
|
|
558
|
+
// v0.8.10: hardGate is volatile and joins the card as a boolean toggle
|
|
559
|
+
// (same write path as allGoalsMode: settings mutate → loader volatile
|
|
560
|
+
// commit → loader/volatile-update → the server hot-installs/disposes
|
|
561
|
+
// the gate listeners, no restart).
|
|
562
|
+
{ field: 'hardGate', labelKey: 'settings.hardGate', hintKey: 'settings.hardGateHint', kind: 'boolean' },
|
|
551
563
|
// v0.8.3: the three model fields are kind 'model' — datalist-backed
|
|
552
564
|
// dropdown over the live host catalog, manual entry still accepted
|
|
553
565
|
// (a datalist input is a free text input); invalid configured values
|
|
@@ -696,6 +708,36 @@ window.__ModuleLoader__.load({
|
|
|
696
708
|
delete next[field];
|
|
697
709
|
return next;
|
|
698
710
|
});
|
|
711
|
+
// v0.8.7: focus parking for the model fields. A native datalist
|
|
712
|
+
// filters its options by the input's CURRENT text, so a field that
|
|
713
|
+
// already holds a configured model unfolded to exactly that one
|
|
714
|
+
// option. Focusing such a field PARKS its text here and blanks the
|
|
715
|
+
// input, which makes the datalist unfold the whole catalog; leaving
|
|
716
|
+
// the field (blur/Enter) with no edit restores the parked text and
|
|
717
|
+
// writes nothing — merely looking at the list can never unset a
|
|
718
|
+
// configured model. `edited` records that the user typed or picked
|
|
719
|
+
// an option while parked: only then does the regular commit run
|
|
720
|
+
// (and a deliberate clear still lands as an unset op).
|
|
721
|
+
// `hadDraft` remembers whether the parked text was an uncommitted
|
|
722
|
+
// draft (keep it) or the snapshot value (restore = clear the draft).
|
|
723
|
+
const [focus, setFocus] = react.useState({});
|
|
724
|
+
const parkFocus = (field, value, hadDraft) => setFocus((f) => ({ ...f, [field]: { value, hadDraft, edited: false } }));
|
|
725
|
+
const markFocusEdited = (field) => setFocus((f) => {
|
|
726
|
+
const entry = f[field];
|
|
727
|
+
if (entry === undefined || entry.edited === true) return f;
|
|
728
|
+
return { ...f, [field]: { value: entry.value, hadDraft: entry.hadDraft, edited: true } };
|
|
729
|
+
});
|
|
730
|
+
const dropFocus = (field) => setFocus((f) => {
|
|
731
|
+
if (!(field in f)) return f;
|
|
732
|
+
const next = { ...f };
|
|
733
|
+
delete next[field];
|
|
734
|
+
return next;
|
|
735
|
+
});
|
|
736
|
+
/** Undo a focus-only visit: the parked text comes back, nothing is written. */
|
|
737
|
+
const restoreParked = (field, entry) => {
|
|
738
|
+
if (entry.hadDraft === true) setDraft(field, entry.value);
|
|
739
|
+
else clearDraft(field);
|
|
740
|
+
};
|
|
699
741
|
|
|
700
742
|
// One write for any volatile field; the toggle is
|
|
701
743
|
// writeField('allGoalsMode', !on). Both write paths land in the same
|
|
@@ -784,7 +826,31 @@ window.__ModuleLoader__.load({
|
|
|
784
826
|
return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
|
|
785
827
|
}
|
|
786
828
|
|
|
829
|
+
// v0.8.10: boolean fields render the same switch face as the header
|
|
830
|
+
// allGoalsMode toggle, per field. The write is the shared
|
|
831
|
+
// writeField(field, !current) path (set op, no drafts involved).
|
|
832
|
+
const booleanRow = (spec) => {
|
|
833
|
+
const on = snap.status === 'ready' && snap.value != null && snap.value[spec.field] === true;
|
|
834
|
+
return jsx.jsx('div', { style: settingsStyles.field, children:
|
|
835
|
+
jsx.jsxs('div', { style: { ...settingsStyles.row }, children: [
|
|
836
|
+
jsx.jsxs('div', { style: settingsStyles.main, children: [
|
|
837
|
+
jsx.jsx('div', { style: settingsStyles.title, children: t(spec.labelKey) }),
|
|
838
|
+
jsx.jsx('div', { style: settingsStyles.desc, children: t(spec.hintKey) }),
|
|
839
|
+
] }),
|
|
840
|
+
snap.status === 'ready'
|
|
841
|
+
? jsx.jsx('button', {
|
|
842
|
+
type: 'button', role: 'switch', 'aria-checked': on, 'aria-label': t(spec.labelKey),
|
|
843
|
+
disabled, onClick: () => writeField(spec.field, !on),
|
|
844
|
+
style: { ...settingsStyles.toggle, ...(on ? settingsStyles.trackOn : settingsStyles.trackOff) },
|
|
845
|
+
children: jsx.jsx('span', { style: { ...settingsStyles.knob, ...(on ? settingsStyles.knobOn : settingsStyles.knobOff) } }),
|
|
846
|
+
})
|
|
847
|
+
: null,
|
|
848
|
+
] })
|
|
849
|
+
}, spec.field);
|
|
850
|
+
};
|
|
851
|
+
|
|
787
852
|
const fieldRow = (spec) => {
|
|
853
|
+
if (spec.kind === 'boolean') return booleanRow(spec);
|
|
788
854
|
// v0.8.3: availability verdict for the configured (snapshot) value —
|
|
789
855
|
// drafts are uncommitted text and never warn. Only a loaded catalog
|
|
790
856
|
// can flag; an empty/failed catalog keeps every value "available".
|
|
@@ -792,6 +858,20 @@ window.__ModuleLoader__.load({
|
|
|
792
858
|
const modelUnavailable = spec.kind === 'model'
|
|
793
859
|
&& !isConfiguredModelAvailable(configuredValue, catalogSnap);
|
|
794
860
|
const listId = 'cybernetics-models-' + spec.field;
|
|
861
|
+
// v0.8.7: a focused model field is "parked" — its text moved to
|
|
862
|
+
// the placeholder and the input blanked so the datalist unfolds
|
|
863
|
+
// every option instead of filtering down to the current value.
|
|
864
|
+
const parked = focus[spec.field];
|
|
865
|
+
const inputText = drafts[spec.field] !== undefined ? drafts[spec.field] : configuredValue;
|
|
866
|
+
// Emptying the box is what lets the native datalist list every
|
|
867
|
+
// model (it filters by the input text); the parked text stays
|
|
868
|
+
// visible as the placeholder. Guarded so a second click into an
|
|
869
|
+
// already-parked field never clears text the user just typed.
|
|
870
|
+
const parkCurrent = () => {
|
|
871
|
+
if (focus[spec.field] !== undefined) return;
|
|
872
|
+
const text = drafts[spec.field] !== undefined ? String(drafts[spec.field]) : configuredValue;
|
|
873
|
+
if (text !== '') { parkFocus(spec.field, text, drafts[spec.field] !== undefined); setDraft(spec.field, ''); }
|
|
874
|
+
};
|
|
795
875
|
return jsx.jsxs('div', { style: settingsStyles.field, children: [
|
|
796
876
|
jsx.jsx('div', { style: settingsStyles.title, children: t(spec.labelKey) }),
|
|
797
877
|
jsx.jsx('div', { style: settingsStyles.desc, children: t(spec.hintKey) }),
|
|
@@ -807,16 +887,45 @@ window.__ModuleLoader__.load({
|
|
|
807
887
|
'aria-label': t(spec.labelKey),
|
|
808
888
|
'aria-invalid': invalid === spec.field || undefined,
|
|
809
889
|
disabled,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
890
|
+
placeholder: parked !== undefined ? parked.value : undefined,
|
|
891
|
+
value: inputText,
|
|
892
|
+
onChange: (e) => {
|
|
893
|
+
setDraft(spec.field, e && e.target ? String(e.target.value) : '');
|
|
894
|
+
// Typing/picking while parked turns the visit into a real
|
|
895
|
+
// edit, so the commit paths below apply unchanged.
|
|
896
|
+
markFocusEdited(spec.field);
|
|
897
|
+
},
|
|
898
|
+
onFocus: spec.kind === 'model' && catalogOptions.length > 0 ? parkCurrent : undefined,
|
|
899
|
+
// A click on the already-focused input must also unfold the
|
|
900
|
+
// list (Escape, or a list closed without blur, restores the
|
|
901
|
+
// value and leaves the field focused — no second onFocus).
|
|
902
|
+
onClick: spec.kind === 'model' && catalogOptions.length > 0 ? parkCurrent : undefined,
|
|
814
903
|
onKeyDown: (e) => {
|
|
815
904
|
if (!e) return;
|
|
816
|
-
if (e.key === 'Enter')
|
|
817
|
-
|
|
905
|
+
if (e.key === 'Enter') {
|
|
906
|
+
const entry = focus[spec.field];
|
|
907
|
+
if (entry !== undefined) {
|
|
908
|
+
dropFocus(spec.field);
|
|
909
|
+
// Enter on an untouched parked field = no edit: restore
|
|
910
|
+
// instead of writing an unset. A parked field the user
|
|
911
|
+
// really edited keeps the v0.8.1 commit path (blank =
|
|
912
|
+
// unset).
|
|
913
|
+
if (entry.edited !== true) { restoreParked(spec.field, entry); return; }
|
|
914
|
+
}
|
|
915
|
+
commitField(spec);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (e.key === 'Escape') { dropFocus(spec.field); clearDraft(spec.field); setInvalid(''); }
|
|
919
|
+
},
|
|
920
|
+
onBlur: () => {
|
|
921
|
+
const entry = focus[spec.field];
|
|
922
|
+
if (entry !== undefined) {
|
|
923
|
+
dropFocus(spec.field);
|
|
924
|
+
const text = (drafts[spec.field] ?? '').trim();
|
|
925
|
+
if (text === '' && entry.edited !== true) { restoreParked(spec.field, entry); return; }
|
|
926
|
+
}
|
|
927
|
+
commitField(spec);
|
|
818
928
|
},
|
|
819
|
-
onBlur: () => commitField(spec),
|
|
820
929
|
style: { ...settingsStyles.input, ...(invalid === spec.field ? settingsStyles.inputInvalid : {}) },
|
|
821
930
|
}),
|
|
822
931
|
spec.kind === 'model' && catalogOptions.length > 0
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -322,7 +322,11 @@ export async function startRound(
|
|
|
322
322
|
goal: { objective: goal.objective },
|
|
323
323
|
state: { round, doneSummary: state.doneSummary },
|
|
324
324
|
subgoal: state.subgoal,
|
|
325
|
-
|
|
325
|
+
// Never hand the workflow binder an own property whose value is
|
|
326
|
+
// undefined: structuredClone keeps it and the host-side json()
|
|
327
|
+
// (snapshotJsonValue) rejects nested undefined — the round dies at
|
|
328
|
+
// bind time. Omit the key entirely when the goal has no work order.
|
|
329
|
+
...(state.brief !== undefined ? { brief: state.brief } : {}),
|
|
326
330
|
depth: state.depth ?? 'full',
|
|
327
331
|
role: state.role,
|
|
328
332
|
roles: config.roles ?? {},
|
package/src/executor.ts
CHANGED
|
@@ -366,7 +366,10 @@ export async function startTeamRound(
|
|
|
366
366
|
goal: { objective: input.objective },
|
|
367
367
|
state: { round: input.round, doneSummary: input.doneSummary },
|
|
368
368
|
subgoal: input.subgoal,
|
|
369
|
-
|
|
369
|
+
// Same binder rule as controller.startRound: an own property with
|
|
370
|
+
// value undefined is kept by structuredClone and rejected by the
|
|
371
|
+
// host-side json() — omit the key when there is no work order.
|
|
372
|
+
...(state.brief !== undefined ? { brief: state.brief } : {}),
|
|
370
373
|
depth: 'full',
|
|
371
374
|
role: state.role,
|
|
372
375
|
roles: config.roles ?? {},
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { randomUUID } from 'node:crypto'
|
|
25
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { mkdir, readFile, rename, writeFile, appendFile } from 'node:fs/promises'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import { fileURLToPath } from 'node:url'
|
|
28
28
|
import z from '@deepseek-ai/schemastery'
|
|
@@ -96,6 +96,30 @@ export interface Config extends ControllerConfig {
|
|
|
96
96
|
gateAllowlist?: string[]
|
|
97
97
|
/** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
|
|
98
98
|
gateAllowlistExtra?: string[]
|
|
99
|
+
/**
|
|
100
|
+
* Approval relay (v0.8.5, default true, hardGate companion): delegated
|
|
101
|
+
* children (workflow subagents, teammates) inherit the host's pinned
|
|
102
|
+
* `never` approval policy, so a sandbox escalation a task agent needs
|
|
103
|
+
* (e.g. writing outside the session workspace) fails deterministically
|
|
104
|
+
* before any answerer sees it. When on, the plugin flips each delegated
|
|
105
|
+
* child's effective policy to `ask` at creation (one durable
|
|
106
|
+
* `approval/policy` session event — later events win over the
|
|
107
|
+
* delegation-seeded pin) and registers an OUTERMOST `approval/request`
|
|
108
|
+
* answerer that re-issues a child's request ON THE TOP-LEVEL SESSION AGENT
|
|
109
|
+
* through the host's own approval service — i.e. the native Allow once /
|
|
110
|
+
* Reject panel of the conversation the user is watching. Outermost is not
|
|
111
|
+
* an optimization: the host's api-remotes registers its `approval/request`
|
|
112
|
+
* browser forwarder at boot, before every user plugin; once the browser's
|
|
113
|
+
* approval answerer claims a child request, that per-session panel never
|
|
114
|
+
* calls `next()`, so an inner relay listener would never run at all (the
|
|
115
|
+
* last-hop defect this field fixes). When the top-level turn is closed (a
|
|
116
|
+
* continuable child outliving it) the relay falls back to the
|
|
117
|
+
* user-questions (ask_user_question) channel on the same agent, then to the
|
|
118
|
+
* native chain. Denials stay explicit: relay failures answer `rejected`,
|
|
119
|
+
* an aborted child run answers `cancelled`, and a child with no live
|
|
120
|
+
* top-level agent falls through to the native chain.
|
|
121
|
+
*/
|
|
122
|
+
gateApprovalRelay?: boolean
|
|
99
123
|
/**
|
|
100
124
|
* All-goals mode (v0.7, default false): EVERY user request — including
|
|
101
125
|
* trivial read-only questions and lightweight analysis — must go through a
|
|
@@ -124,9 +148,11 @@ export interface Config extends ControllerConfig {
|
|
|
124
148
|
// effect on the next goal round. roles stays in the schema but is NOT
|
|
125
149
|
// volatile (v0.8.4): the panel serves only volatile fields, so it no longer
|
|
126
150
|
// shows in the GUI — advanced users (agent-guided) configure it directly in
|
|
127
|
-
// the profile config yaml; a host reload applies it.
|
|
128
|
-
//
|
|
129
|
-
//
|
|
151
|
+
// the profile config yaml; a host reload applies it. v0.8.10: hardGate itself
|
|
152
|
+
// is volatile and toggleable from the settings card (the gate wiring is
|
|
153
|
+
// installed/disposed live on loader/volatile-update); the fine-grained gate
|
|
154
|
+
// fields (gateMode/gatedTools/gateAllowlist/gateAllowlistExtra) and
|
|
155
|
+
// gateApprovalRelay stay non-volatile config-document fields.
|
|
130
156
|
export const Config = z.object({
|
|
131
157
|
/** Model-facing tool name. */
|
|
132
158
|
toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
|
|
@@ -182,16 +208,32 @@ export const Config = z.object({
|
|
|
182
208
|
* Volatile: enforced at every launch, live on the next tool call.
|
|
183
209
|
*/
|
|
184
210
|
maxConcurrentRounds: z.number().default(0).volatile().description('全局并行轮数上限(缺省 0 = 不限):在跑轮数达到上限时拒绝启动新一轮,防止并行风暴。保存后即时生效,无需重载。'),
|
|
185
|
-
/**
|
|
186
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Hard orchestration gate (default true). Volatile (v0.8.10): the settings
|
|
213
|
+
* card exposes a live toggle; on loader/volatile-update apply() disposes or
|
|
214
|
+
* (re)installs the gate wiring (assemble waterfall + tools/pre-execute deny
|
|
215
|
+
* + approval relay) without a plugin remount, mirroring the
|
|
216
|
+
* registerGuidanceSection re-derive pattern.
|
|
217
|
+
*/
|
|
218
|
+
hardGate: z.boolean().default(true).volatile().description('硬编排门(默认开启):装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。设置卡片可开关,保存后即时生效(热装卸门禁监听),无需重启宿主。'),
|
|
187
219
|
/** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
|
|
188
220
|
gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
|
|
189
|
-
/**
|
|
190
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Gate semantics. v0.8.6: NO schema-level default — an absent value means
|
|
223
|
+
* "derive from hardGate" in apply() (`hardGate ? 'allowlist' : 'denylist'`).
|
|
224
|
+
* The old `default('denylist')` erased the user's silence and silently made
|
|
225
|
+
* every hardGate deployment a partial gate (only bash/edit/write), which is
|
|
226
|
+
* how the bsk_ / taskboard_ / schedule_ / farm_ / uno_ / ppt_ writes stayed
|
|
227
|
+
* alive. Explicit configurations are still honored verbatim, denylist
|
|
228
|
+
* included.
|
|
229
|
+
*/
|
|
230
|
+
gateMode: z.union(['denylist', 'allowlist'] as const).description('门语义(缺省随 hardGate 推导):hardGate 开启且未显式配置时按 allowlist 生效;hardGate 关闭时按 denylist。allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身;denylist 只隐藏 gatedTools(默认 bash/edit/write),其余写工具(bsk_*/taskboard_*/schedule_*/farm_*/uno_*/ppt_*/config_restore 等)仍然可用——属残留风险,仅在明确接受该风险时才显式配置。'),
|
|
191
231
|
/** Allowlist-mode survival set (default DEFAULT_GATE_ALLOWLIST). */
|
|
192
232
|
gateAllowlist: z.array(z.string()).description('allowlist 模式生存工具集(缺省内置 DEFAULT_GATE_ALLOWLIST)。'),
|
|
193
233
|
/** Extra tool names unioned onto the allowlist. */
|
|
194
234
|
gateAllowlistExtra: z.array(z.string()).description('并集追加进 allowlist 的额外工具名。'),
|
|
235
|
+
/** Approval relay for delegated children (default true). */
|
|
236
|
+
gateApprovalRelay: z.boolean().default(true).description('子代理审批透传(默认开启,hardGate 伴随项):把委派子代理的权限升级请求以"最外层 answerer"接管,改用顶层 session agent 的原生审批面板(Allow once / Reject)弹给用户裁决——请求落在用户正在看的会话里,而不是子会话那张看不见的面板;顶层回合已关闭时回退到 user-questions(ask_user_question)通道,再回退原生应答链。转发失败按显式拒绝、子代理跑飞按 cancelled 处理。属配置文件维护字段(GUI 面板不展示),修改需插件重载。'),
|
|
195
237
|
/**
|
|
196
238
|
* All-goals mode (v0.7, default false): every user request must go through
|
|
197
239
|
* a cybernetics goal first. Volatile: the settings-panel toggle commits
|
|
@@ -514,19 +556,58 @@ const DEFAULT_GATE_ALLOWLIST = [
|
|
|
514
556
|
'browser_screenshot', 'browser_navigate', 'browser_scroll',
|
|
515
557
|
]
|
|
516
558
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
559
|
+
/**
|
|
560
|
+
* Hard-gate guidance is mode-specific (v0.8.6). In allowlist mode the strong
|
|
561
|
+
* claim is true: every change-class tool is absent from the assembly and
|
|
562
|
+
* denied at dispatch. In denylist mode it was FALSE — only `gatedTools` is
|
|
563
|
+
* removed while bsk_ / taskboard_ / schedule_ / farm_ / uno_ / ppt_ /
|
|
564
|
+
* config_restore remain callable — so the section names the partial stance
|
|
565
|
+
* instead of pretending the loop is structurally enforced.
|
|
566
|
+
*/
|
|
567
|
+
const HARD_GATE_GUIDANCE_ALLOWLIST = `
|
|
568
|
+
HARD GATE ACTIVE (allowlist mode): your tool list is restricted to reading, searching, conversation,
|
|
569
|
+
presentation and goal/orchestration verbs — every tool that CHANGES the world (edit/write/bash,
|
|
570
|
+
service lifecycle, board/knowledge/schedule writes, spawning other workers, ...) is STRUCTURALLY
|
|
571
|
+
unavailable to you. Answering a read-only question directly is fine and needs no goal; but the
|
|
572
|
+
moment the user asks for a change — however small — that is your signal to delegate: create a
|
|
573
|
+
cybernetics goal (action 'start' with title+brief) and let a task agent execute it. Never tell the
|
|
574
|
+
user a change is "too small for the loop": the loop is the only way changes get done. If you look
|
|
575
|
+
for a tool to perform a change and do not find it, that is the gate working as intended — delegate
|
|
576
|
+
instead.`
|
|
577
|
+
|
|
578
|
+
const HARD_GATE_GUIDANCE_DENYLIST = `
|
|
579
|
+
HARD GATE ACTIVE (denylist mode): only the tools listed in gatedTools (default bash, edit, write)
|
|
580
|
+
are removed from your tool list. This mode is PARTIAL, not structural: other change-class tools are
|
|
581
|
+
still present and callable — bsk_* (browser automation), taskboard_* (board writes), schedule_*
|
|
582
|
+
(cron reminders), farm_* (service lifecycle), uno_* / ppt_* (document generators), config_restore,
|
|
583
|
+
subagent/team spawning, and the rest of the mutation long tail. Their availability is NOT
|
|
584
|
+
permission to do task work yourself — calling one directly bypasses the goal loop this deployment
|
|
585
|
+
requires. The moment the user asks for a change, however small, create a cybernetics goal (action
|
|
586
|
+
'start' with title+brief) and let a task agent execute it. Do not route around a missing tool with
|
|
587
|
+
another write tool, and do not describe the gate as total: it hides gatedTools only.`
|
|
588
|
+
|
|
589
|
+
/** Effective gate semantics: explicit config wins; otherwise follow hardGate. */
|
|
590
|
+
function resolveGateMode(config: Partial<Config>, hardGate: boolean): 'denylist' | 'allowlist' {
|
|
591
|
+
if (config.gateMode === 'denylist' || config.gateMode === 'allowlist') return config.gateMode
|
|
592
|
+
return hardGate ? 'allowlist' : 'denylist'
|
|
593
|
+
}
|
|
526
594
|
|
|
527
595
|
export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
528
596
|
const toolName = config.toolName ?? 'cybernetics'
|
|
529
|
-
|
|
597
|
+
// v0.8.10: hardGate is a volatile field, so it may arrive as a live
|
|
598
|
+
// reference whose value the settings card commits in place — read it at use
|
|
599
|
+
// time instead of capturing it once (same pattern as allGoalsMode below).
|
|
600
|
+
// Fallback false keeps the plain-object contract unchanged: a config that
|
|
601
|
+
// simply omits hardGate installs no gate, exactly as before; the schema's
|
|
602
|
+
// default(true) reaches the runtime through the live reference the volatile
|
|
603
|
+
// resolution provides.
|
|
604
|
+
const hardGate = (): boolean => readConfigField(config.hardGate, false) === true
|
|
605
|
+
// v0.8.6 default-safe gate semantics: a user who turns hardGate on but says
|
|
606
|
+
// nothing about gateMode gets the structural allowlist, not the leaky
|
|
607
|
+
// denylist. hardGate=false resolves to denylist but no gate is installed,
|
|
608
|
+
// so its behavior is unchanged. gateMode itself stays non-volatile: its
|
|
609
|
+
// plain value is re-read on every (re)install, so a hot hardGate flip
|
|
610
|
+
// re-derives the mode via resolveGateMode at that moment.
|
|
530
611
|
// allGoalsMode is a volatile field: under the loader's schema resolution it
|
|
531
612
|
// arrives as a live reference whose value the settings panel commits in
|
|
532
613
|
// place (no remount), so read it at use time instead of capturing it once.
|
|
@@ -556,22 +637,37 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
556
637
|
if (disposeSection !== undefined) disposeSection()
|
|
557
638
|
disposeSection = undefined
|
|
558
639
|
if (config.implicitTrigger === false) return
|
|
640
|
+
// v0.8.10: the hard-gate wording is re-derived per call — hardGate is
|
|
641
|
+
// volatile, so its guidance segment follows the live toggle too.
|
|
642
|
+
const gateMode = resolveGateMode(config, hardGate())
|
|
643
|
+
const hardGateGuidance = hardGate()
|
|
644
|
+
? (gateMode === 'allowlist' ? HARD_GATE_GUIDANCE_ALLOWLIST : HARD_GATE_GUIDANCE_DENYLIST)
|
|
645
|
+
: ''
|
|
559
646
|
disposeSection = ctx.systemPrompt.section({
|
|
560
647
|
name: `tool:${toolName}`,
|
|
561
648
|
order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
|
|
562
|
-
text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') +
|
|
649
|
+
text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') + hardGateGuidance,
|
|
563
650
|
})
|
|
564
651
|
}
|
|
565
652
|
registerGuidanceSection()
|
|
566
653
|
disposers.push(() => { if (disposeSection !== undefined) disposeSection() })
|
|
567
|
-
ctx.on?.('loader/volatile-update', () => { registerGuidanceSection() })
|
|
568
654
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
655
|
+
/**
|
|
656
|
+
* v0.8.10: install (or tear down) the hard-gate wiring in step with the
|
|
657
|
+
* volatile hardGate toggle. Idempotent: each call first disposes the
|
|
658
|
+
* previous installation, so repeated toggles never double-register. A
|
|
659
|
+
* hot false→true re-derives gateMode via resolveGateMode from the CURRENT
|
|
660
|
+
* resolution (explicit gateMode config wins; otherwise allowlist).
|
|
661
|
+
*/
|
|
662
|
+
let disposeGate: (() => void) | undefined
|
|
663
|
+
function installGate(): void {
|
|
664
|
+
if (disposeGate !== undefined) { disposeGate(); disposeGate = undefined }
|
|
665
|
+
if (!hardGate()) return
|
|
666
|
+
const gateDisposers: Array<() => void> = []
|
|
667
|
+
// The gate is being installed, so hardGate() is true here; resolveGateMode
|
|
668
|
+
// then honors an explicit gateMode or derives the structural allowlist.
|
|
669
|
+
const gateMode = resolveGateMode(config, true)
|
|
670
|
+
const allowlistMode = gateMode === 'allowlist'
|
|
575
671
|
// NOTE: schema-resolved config turns an unset z.array field into [] (not
|
|
576
672
|
// undefined), so fall back on length, not on ?? — otherwise the gate
|
|
577
673
|
// would silently keep/gate nothing.
|
|
@@ -581,6 +677,10 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
581
677
|
toolName,
|
|
582
678
|
])
|
|
583
679
|
const gated = new Set(config.gatedTools?.length ? config.gatedTools : DEFAULT_GATED_TOOLS)
|
|
680
|
+
// The hard gate: a system-prompt/assemble waterfall listener that strips
|
|
681
|
+
// execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
|
|
682
|
+
// (task/verify/triage) and team teammates are never in agents.roots(),
|
|
683
|
+
// so their assemblies pass through untouched with full tool access.
|
|
584
684
|
const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
|
|
585
685
|
const scope = context?.scope
|
|
586
686
|
if (!scope) return next()
|
|
@@ -603,7 +703,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
603
703
|
}
|
|
604
704
|
return next()
|
|
605
705
|
})
|
|
606
|
-
|
|
706
|
+
gateDisposers.push(() => listenerDisposer?.())
|
|
607
707
|
|
|
608
708
|
// Execution-layer gate (the assembly filter above only hides names; the
|
|
609
709
|
// host resolves any registered tool by name, so an unadvertised call must
|
|
@@ -629,8 +729,275 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
629
729
|
'Delegate the change: create a cybernetics goal (action "start" with title+brief) and let a task agent execute it.',
|
|
630
730
|
})
|
|
631
731
|
})
|
|
632
|
-
|
|
732
|
+
gateDisposers.push(() => preDisposer?.())
|
|
733
|
+
|
|
734
|
+
// v0.8.5 approval relay (gateApprovalRelay, default true): delegated
|
|
735
|
+
// children (workflow subagents, teammates) inherit the host's PINNED
|
|
736
|
+
// `never` approval policy (dsh-subagent/lib/index.js:413
|
|
737
|
+
// captureDelegatedPolicyOverrides pins 'never' for every delegation;
|
|
738
|
+
// dsh-user-approval/lib/index.js:175 decide() then short-circuits the ask
|
|
739
|
+
// to 'rejected' BEFORE any answerer runs), so a task agent's sandbox
|
|
740
|
+
// escalation (e.g. writing outside the session workspace) can never reach
|
|
741
|
+
// the user. Relay = two listeners:
|
|
742
|
+
//
|
|
743
|
+
// (1) agent/created — flip the child's effective policy to 'ask' by
|
|
744
|
+
// appending one plain `approval/policy {policy:'ask'}` event to the
|
|
745
|
+
// child session (exactly what the host's setApprovalPolicy appends;
|
|
746
|
+
// later child switches win over the delegation-seeded pin). Live
|
|
747
|
+
// evidence: a real delegated session log carries the pin
|
|
748
|
+
// {policy:'never', source:'delegation'} at seq 2 and the plugin's
|
|
749
|
+
// {policy:'ask'} at seq 3 (52 ms later).
|
|
750
|
+
// (2) approval/request — OUTERMOST answerer (prepend:true) for delegated
|
|
751
|
+
// children only. It does NOT give the native chain the first turn:
|
|
752
|
+
// the host's api-remotes registers an approval/request waterfall
|
|
753
|
+
// listener at boot (dsh-api-remotes/lib/index.js:21-28,147
|
|
754
|
+
// API_REMOTE_FORWARDED_EVENTS → forwardWaterfall at :233) and
|
|
755
|
+
// forwards every request to the connected browser, where
|
|
756
|
+
// dsh-client-ui-approval/lib/client.js:284 answerApproval claims it
|
|
757
|
+
// and awaits a per-session panel that is INVISIBLE whenever the user
|
|
758
|
+
// is watching the parent conversation. A listener registered after
|
|
759
|
+
// api-remotes is INNER and is never reached (cordis waterfall runs
|
|
760
|
+
// outermost-first — cordis/lib/index.js:317-325), so the previous
|
|
761
|
+
// bounded-window `next()` race could not fire at all: the child hung
|
|
762
|
+
// on a panel nobody could see. prepend makes the relay the FIRST
|
|
763
|
+
// listener; it re-issues the request ON THE TOP-LEVEL LIVE ANCESTOR
|
|
764
|
+
// AGENT through the host's own ApprovalService
|
|
765
|
+
// (ctx.get('approval').request) — the native Allow once / Reject
|
|
766
|
+
// panel of the conversation the user is actually watching, audited as
|
|
767
|
+
// approval/asked + approval/decided on that session. The child's
|
|
768
|
+
// callId is deliberately NOT forwarded (a child ToolCallId must not
|
|
769
|
+
// correlate with a tool call in the parent transcript). When the
|
|
770
|
+
// top-level turn is closed (ApprovalService.request requires an open
|
|
771
|
+
// turn: dsh-user-approval/lib/index.js:130) the relay falls back to
|
|
772
|
+
// the user-questions channel on the same agent (an ask_user_question
|
|
773
|
+
// card in the same composer). A native 'unavailable' (no answering
|
|
774
|
+
// client) ALSO falls through to the question card — v0.8.8 fix: the
|
|
775
|
+
// live failure was asked → decided unavailable in 9 ms with no visible
|
|
776
|
+
// prompt. Denial semantics stay explicit: relay errors answer
|
|
777
|
+
// 'rejected', an aborted child run answers 'cancelled', a question
|
|
778
|
+
// channel that is also unavailable answers terminal 'unavailable',
|
|
779
|
+
// and a child with no live top-level agent falls through to next()
|
|
780
|
+
// (the host's fail-closed native semantics) — never a silent success.
|
|
781
|
+
const gateApprovalRelay = config.gateApprovalRelay !== false
|
|
782
|
+
if (gateApprovalRelay) {
|
|
783
|
+
// Delegated children carry `origin: 'subagent'` (dsh-subagent sets it on
|
|
784
|
+
// the child header; the live session log of this very task agent shows
|
|
785
|
+
// it). Forks keep a parentSession for lineage but are independent roots,
|
|
786
|
+
// so they are excluded here: `delegationDepth > 0` is the fallback when
|
|
787
|
+
// a host build omits `origin`.
|
|
788
|
+
const isDelegatedChild = (agent: any): boolean => {
|
|
789
|
+
const header = agent?.session?.header
|
|
790
|
+
if (header === undefined || header === null) return false
|
|
791
|
+
if (header.origin === 'subagent') return true
|
|
792
|
+
return header.parentSession !== undefined && typeof header.delegationDepth === 'number' && header.delegationDepth > 0
|
|
793
|
+
}
|
|
794
|
+
const agentById = (id: string): any => {
|
|
795
|
+
try {
|
|
796
|
+
const direct = ctx.agents?.get?.(id)
|
|
797
|
+
if (direct !== undefined && direct !== null) return direct
|
|
798
|
+
} catch { /* registry lookup is best-effort */ }
|
|
799
|
+
try {
|
|
800
|
+
return (ctx.agents?.roots?.() ?? []).find((r: any) =>
|
|
801
|
+
r?.id === id || r?.session?.header?.id === id)
|
|
802
|
+
} catch { return undefined }
|
|
803
|
+
}
|
|
804
|
+
// Walk the durable parentSession chain up to the live TOP-LEVEL agent:
|
|
805
|
+
// the conversation the user watches. Undefined when a link is dead (a
|
|
806
|
+
// continuable child that outlived its parent) — the relay then leaves
|
|
807
|
+
// the request to the native chain.
|
|
808
|
+
const topLevelAgentOf = (agent: any): any => {
|
|
809
|
+
let cursor = agent
|
|
810
|
+
const visited = new Set<string>()
|
|
811
|
+
while (true) {
|
|
812
|
+
const parentId = cursor?.session?.header?.parentSession
|
|
813
|
+
if (parentId === undefined) return cursor
|
|
814
|
+
if (visited.has(parentId)) return undefined
|
|
815
|
+
visited.add(parentId)
|
|
816
|
+
const parent = agentById(parentId)
|
|
817
|
+
if (parent === undefined) return undefined
|
|
818
|
+
cursor = parent
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const approvalServiceOf = (agent: any): any => {
|
|
822
|
+
try {
|
|
823
|
+
const fromCtx = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
|
|
824
|
+
if (fromCtx !== undefined) return fromCtx
|
|
825
|
+
} catch { /* service not provided in this deployment */ }
|
|
826
|
+
try { return agent?.ctx?.get?.('approval') } catch { return undefined }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const createdDisposer = ctx.on('agent/created', (payload: any) => {
|
|
830
|
+
const agent = payload?.agent
|
|
831
|
+
if (!isDelegatedChild(agent)) return
|
|
832
|
+
try {
|
|
833
|
+
agent.session.append('approval/policy', { policy: 'ask' })
|
|
834
|
+
} catch {
|
|
835
|
+
// agent/created is a serial event: a throw fails child creation.
|
|
836
|
+
// A relay must never break delegation — leave the pinned policy.
|
|
837
|
+
}
|
|
838
|
+
})
|
|
839
|
+
gateDisposers.push(() => createdDisposer?.())
|
|
840
|
+
|
|
841
|
+
const RELAY_QUESTION_ID = 'cybernetics-approval-relay'
|
|
842
|
+
const RELAY_ALLOW = '允许一次(Allow once)'
|
|
843
|
+
const RELAY_REJECT = '拒绝(Reject)'
|
|
844
|
+
// 'unavailable' is deliberately NOT terminal here: it means "the native
|
|
845
|
+
// panel had no answering client" (dsh-user-approval/lib/index.js:176),
|
|
846
|
+
// which is exactly the v0.8.7 live failure (asked → decided unavailable
|
|
847
|
+
// in 9 ms). The relay then still falls through to the question card so
|
|
848
|
+
// the user gets a VISIBLE prompt; only when the question channel is also
|
|
849
|
+
// unavailable does the request end as a terminal 'unavailable'.
|
|
850
|
+
const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected', 'cancelled'])
|
|
851
|
+
|
|
852
|
+
// Observability (v0.8.8, persisted v0.8.9): every relay decision logs
|
|
853
|
+
// the path taken, outcome, latency and the resolved top-level target —
|
|
854
|
+
// to the host logger AND to `<workspace>/.cybernetics/relay-log.jsonl`.
|
|
855
|
+
// The file is the ground truth a later acceptance run can read without
|
|
856
|
+
// host stdout or browser console access (the v0.8.8 live failure was
|
|
857
|
+
// indistinguishable between "stale build" and "both channels down").
|
|
858
|
+
const relayLog = (agent: any, path: string, outcome: string, startedAt: number): void => {
|
|
859
|
+
const rootId = agent?.session?.header?.id ?? agent?.id ?? 'unknown'
|
|
860
|
+
const line = `cybernetics approval-relay: path=${path} outcome=${outcome} ` +
|
|
861
|
+
`root=${rootId} elapsed=${Date.now() - startedAt}ms`
|
|
862
|
+
try {
|
|
863
|
+
const logger = (ctx as { logger?: { debug?: (m: string) => void; info?: (m: string) => void } }).logger
|
|
864
|
+
if (typeof logger?.debug === 'function') logger.debug(line)
|
|
865
|
+
else if (typeof logger?.info === 'function') logger.info(line)
|
|
866
|
+
else console.debug(line)
|
|
867
|
+
} catch { console.debug(line) }
|
|
868
|
+
// Fire-and-forget disk append; a relay must never fail because its own
|
|
869
|
+
// audit trail could not be written.
|
|
870
|
+
try {
|
|
871
|
+
const wd = (ctx as { workingDirectory?: { get?: (s: unknown) => unknown } }).workingDirectory
|
|
872
|
+
const ws = typeof wd?.get === 'function' ? wd.get(agent?.session) : undefined
|
|
873
|
+
if (typeof ws !== 'string' || ws.length === 0) return
|
|
874
|
+
const entry = JSON.stringify({
|
|
875
|
+
ts: new Date().toISOString(),
|
|
876
|
+
root: rootId,
|
|
877
|
+
path,
|
|
878
|
+
outcome,
|
|
879
|
+
elapsedMs: Date.now() - startedAt,
|
|
880
|
+
}) + '\n'
|
|
881
|
+
void mkdir(join(ws, '.cybernetics'), { recursive: true })
|
|
882
|
+
.then(() => appendFile(join(ws, '.cybernetics', 'relay-log.jsonl'), entry, 'utf8'))
|
|
883
|
+
.catch(() => { /* observability only */ })
|
|
884
|
+
} catch { /* observability only */ }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Ask the top-level agent through the host's native approval channel,
|
|
888
|
+
// falling back to the user-questions channel, then to a terminal
|
|
889
|
+
// 'unavailable'. Returns a closed approval outcome.
|
|
890
|
+
const relayToSessionAgent = async (req: any, root: any): Promise<string> => {
|
|
891
|
+
const childId = req.agent?.session?.header?.id ?? req.agent?.id ?? 'unknown'
|
|
892
|
+
const rootId = root?.session?.header?.id ?? root?.id ?? 'unknown'
|
|
893
|
+
const startedAt = Date.now()
|
|
894
|
+
const reason = typeof req.reason === 'string' && req.reason.length > 0 ? req.reason : undefined
|
|
895
|
+
const approval = approvalServiceOf(root)
|
|
896
|
+
if (approval !== undefined && typeof approval.request === 'function') {
|
|
897
|
+
try {
|
|
898
|
+
const outcome = await approval.request({
|
|
899
|
+
agent: root,
|
|
900
|
+
toolName: req.toolName,
|
|
901
|
+
reason: `[sub-agent ${childId}] ${reason ?? `tool "${req.toolName}" requires approval`}`,
|
|
902
|
+
displayReason: {
|
|
903
|
+
en: `Sub-agent ${childId} requests privileged execution of ${req.toolName}${reason === undefined ? '' : `: ${reason}`}`,
|
|
904
|
+
zh: `子代理 ${childId} 请求越权执行工具 ${req.toolName}${reason === undefined ? '' : `:${reason}`}`,
|
|
905
|
+
},
|
|
906
|
+
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
907
|
+
})
|
|
908
|
+
if (APPROVAL_OUTCOMES.has(outcome)) {
|
|
909
|
+
relayLog(root, 'native-panel', outcome, startedAt)
|
|
910
|
+
return outcome
|
|
911
|
+
}
|
|
912
|
+
// 'unavailable' (or any unknown value): the native panel found no
|
|
913
|
+
// answering client — keep falling through to the question card.
|
|
914
|
+
} catch {
|
|
915
|
+
// No open turn on the session agent (continuable child outliving
|
|
916
|
+
// it) or the audit pair failed: the question channel does not
|
|
917
|
+
// require an open turn, so try it before giving up.
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// v0.8.9 ROOT-CAUSE FIX: plain property access `ctx.userQuestions` on
|
|
921
|
+
// this plugin's context throws cordis' "cannot get property without
|
|
922
|
+
// inject" (the plugin's `inject` list does not declare the service),
|
|
923
|
+
// so the previous `try { ctx.userQuestions }` always fell into the
|
|
924
|
+
// catch and the question-card fallback answered terminal 'unavailable'
|
|
925
|
+
// within milliseconds — the user never saw a card. Resolution now
|
|
926
|
+
// mirrors dsh-plan-mode/lib/index.js:257 (`ctx.get("userQuestions")`,
|
|
927
|
+
// the inject-free reflective read that walks the composition store),
|
|
928
|
+
// with the property access kept as a secondary attempt for host builds
|
|
929
|
+
// where the service is injected under a different graph shape.
|
|
930
|
+
let uq: any
|
|
931
|
+
try {
|
|
932
|
+
const viaGet = typeof (ctx as { get?: (n: string) => unknown }).get === 'function'
|
|
933
|
+
? (ctx as { get: (n: string) => unknown }).get('userQuestions')
|
|
934
|
+
: undefined
|
|
935
|
+
if (viaGet !== undefined && typeof (viaGet as { ask?: unknown }).ask === 'function') uq = viaGet
|
|
936
|
+
} catch { uq = undefined }
|
|
937
|
+
if (uq === undefined) {
|
|
938
|
+
try { uq = ctx.userQuestions } catch { uq = undefined }
|
|
939
|
+
if (uq !== undefined && typeof uq.ask !== 'function') uq = undefined
|
|
940
|
+
}
|
|
941
|
+
if (uq === undefined || typeof uq.ask !== 'function') {
|
|
942
|
+
relayLog(root, 'question-card', 'unavailable', startedAt)
|
|
943
|
+
return 'unavailable'
|
|
944
|
+
}
|
|
945
|
+
if (req.signal?.aborted) {
|
|
946
|
+
relayLog(root, 'question-card', 'cancelled', startedAt)
|
|
947
|
+
return 'cancelled'
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
const answer = await uq.ask({
|
|
951
|
+
questions: [{
|
|
952
|
+
id: RELAY_QUESTION_ID,
|
|
953
|
+
question: `${reason ?? `tool ${req.toolName}`}(子代理 ${childId} 请求越权执行工具 ${req.toolName})`,
|
|
954
|
+
detail: reason,
|
|
955
|
+
header: '子代理审批透传',
|
|
956
|
+
options: [{ label: RELAY_ALLOW }, { label: RELAY_REJECT }],
|
|
957
|
+
multiSelect: false,
|
|
958
|
+
}],
|
|
959
|
+
agent: root,
|
|
960
|
+
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
961
|
+
})
|
|
962
|
+
const selected = (answer?.answers ?? []).find((a: any) => a?.id === RELAY_QUESTION_ID)?.selected ?? []
|
|
963
|
+
const outcome = selected.includes(RELAY_ALLOW) ? 'allowed-once' : 'rejected'
|
|
964
|
+
relayLog(root, 'question-card', outcome, startedAt)
|
|
965
|
+
return outcome
|
|
966
|
+
} catch {
|
|
967
|
+
// Question delivery failed (client gone, answerer refused, run
|
|
968
|
+
// aborted): answer 'cancelled' when the child's own signal fired,
|
|
969
|
+
// otherwise an explicit rejection — the child stops and explains.
|
|
970
|
+
const outcome = req.signal?.aborted ? 'cancelled' : 'rejected'
|
|
971
|
+
relayLog(root, 'question-card', outcome, startedAt)
|
|
972
|
+
return outcome
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const relayDisposer = ctx.on('approval/request', async (req: any, next: () => Promise<string>) => {
|
|
977
|
+
if (!isDelegatedChild(req?.agent)) return next()
|
|
978
|
+
if (req?.signal?.aborted) return 'cancelled'
|
|
979
|
+
const root = topLevelAgentOf(req.agent)
|
|
980
|
+
// No live ancestor, or a malformed child whose chain loops back to
|
|
981
|
+
// itself: never re-ask on the requesting agent (that would re-enter
|
|
982
|
+
// this same waterfall forever).
|
|
983
|
+
if (root === undefined || root === req.agent) {
|
|
984
|
+
relayLog(req.agent, 'next-passthrough', 'next', Date.now())
|
|
985
|
+
return next()
|
|
986
|
+
}
|
|
987
|
+
const outcome = await relayToSessionAgent(req, root)
|
|
988
|
+
return outcome === undefined ? next() : outcome
|
|
989
|
+
}, { prepend: true })
|
|
990
|
+
gateDisposers.push(() => relayDisposer?.())
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
disposeGate = () => { gateDisposers.forEach((d) => d()) }
|
|
633
994
|
}
|
|
995
|
+
installGate()
|
|
996
|
+
disposers.push(() => { if (disposeGate !== undefined) disposeGate() })
|
|
997
|
+
ctx.on?.('loader/volatile-update', () => {
|
|
998
|
+
registerGuidanceSection()
|
|
999
|
+
installGate()
|
|
1000
|
+
})
|
|
634
1001
|
|
|
635
1002
|
const disposer = (ctx.tools as { register: (d: unknown) => () => void }).register({
|
|
636
1003
|
name: toolName,
|
|
@@ -888,9 +1255,12 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
888
1255
|
}
|
|
889
1256
|
}
|
|
890
1257
|
|
|
891
|
-
// Per-call overrides.
|
|
1258
|
+
// Per-call overrides. 'revise' is excluded from the brief override: the
|
|
1259
|
+
// call targets the OLD goal (which gets frozen as superseded below), so
|
|
1260
|
+
// writing the new work order onto it would pollute frozen history — the
|
|
1261
|
+
// revise branch assigns the brief to the successor goal itself.
|
|
892
1262
|
if (args.subgoal) loop.subgoal = args.subgoal
|
|
893
|
-
if (args.brief) loop.brief = args.brief
|
|
1263
|
+
if (args.brief && action !== 'revise') loop.brief = args.brief
|
|
894
1264
|
if (args.depth) loop.depth = args.depth
|
|
895
1265
|
if (args.executor) loop.executor = args.executor
|
|
896
1266
|
if (args.role) loop.role = args.role
|
|
@@ -934,6 +1304,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
934
1304
|
? `cybernetics: 已有 ${running.size} 个运行中的轮,达到全局并行上限 ${cap}(可在设置面板调高 maxConcurrentRounds 或等待完成)`
|
|
935
1305
|
: undefined
|
|
936
1306
|
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Brief visibility in tool results: without it the model cannot tell
|
|
1309
|
+
* whether a round was launched WITH a work order — the result looked
|
|
1310
|
+
* identical either way, and a briefless round silently sent the task
|
|
1311
|
+
* agent in with only objective/title (a no-feedback loop).
|
|
1312
|
+
*/
|
|
1313
|
+
const briefWarning = (target: LoopState): { warning?: string } =>
|
|
1314
|
+
typeof target.brief === 'string' && target.brief.trim().length > 0
|
|
1315
|
+
? {}
|
|
1316
|
+
: {
|
|
1317
|
+
warning:
|
|
1318
|
+
'该 goal 没有工作委托书(brief):task agent 只能看到 objective/title。' +
|
|
1319
|
+
'复杂任务请用 cybernetics revise 补 brief(含背景、路径、验收标准)。',
|
|
1320
|
+
}
|
|
937
1321
|
const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
|
|
938
1322
|
|
|
939
1323
|
/**
|
|
@@ -1067,10 +1451,16 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1067
1451
|
revised: true,
|
|
1068
1452
|
round,
|
|
1069
1453
|
started: true,
|
|
1454
|
+
// Work-order visibility: the successor's effective brief (supplied
|
|
1455
|
+
// or inherited), so the caller can verify the task agent's mandate.
|
|
1456
|
+
workOrder: next.brief
|
|
1457
|
+
? { present: true, chars: next.brief.length, preview: next.brief.slice(0, 80) }
|
|
1458
|
+
: { present: false },
|
|
1070
1459
|
...(aborted ? { aborted } : {}),
|
|
1071
1460
|
abort: effectiveAbort,
|
|
1072
1461
|
revision: { from: previous.goalId, to: newGoalId, reason, at },
|
|
1073
1462
|
...(revisionLog ? { revisionLog } : {}),
|
|
1463
|
+
...briefWarning(next),
|
|
1074
1464
|
}
|
|
1075
1465
|
}
|
|
1076
1466
|
|
|
@@ -1120,7 +1510,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1120
1510
|
|
|
1121
1511
|
if (action === 'start') {
|
|
1122
1512
|
const { round } = await launchBackground(loop)
|
|
1123
|
-
return { goalId: loop.goalId, round, started: true }
|
|
1513
|
+
return { goalId: loop.goalId, round, started: true, ...briefWarning(loop) }
|
|
1124
1514
|
}
|
|
1125
1515
|
|
|
1126
1516
|
// Blocking round (default). Both substrates expose {round, result, dispose}.
|
|
@@ -1135,7 +1525,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
1135
1525
|
applyDecision(loop, result.round, result, decision)
|
|
1136
1526
|
// Round settlement decision applied — republish (round / status / subgoal).
|
|
1137
1527
|
await publishBoard()
|
|
1138
|
-
return { goalId: loop.goalId, round: result.round, decision, result }
|
|
1528
|
+
return { goalId: loop.goalId, round: result.round, decision, result, ...briefWarning(loop) }
|
|
1139
1529
|
},
|
|
1140
1530
|
})
|
|
1141
1531
|
|