@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 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 false)
45
+ ### Hard gate (`hardGate`, default true — active out of the box; v0.8.10: toggleable on the settings card)
46
46
 
47
- At assembly time, execution tools (default bash/edit/write, overridable via `gatedTools`) are **physically removed** from the top-level session agent's tool list — the model cannot see these tools and therefore cannot call them; orchestration turns from exhortation into structure. `gateMode: 'allowlist'` inverts the semantics: the top-level agent keeps only read + conversation + presentation + goal/orchestration verbs (a built-in DEFAULT_GATE_ALLOWLIST, overridable via `gateAllowlist`/`gateAllowlistExtra`), targeting the long tail of change-class tools (farm_*/taskboard_*/schedule_*…). The task/verify/triage sub agents are never subject to the gate and keep all tools.
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 fields** (`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`, non-volatile): not served to the card; edit them in the profile config document (saving triggers a plugin reload).
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, `hardGate`, `gateMode`, `gatedTools`, `gateAllowlist*`, `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).
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`,默认 false)
45
+ ### 硬门(`hardGate`,默认 true——安装即激活;v0.8.10 起设置卡片可开关)
46
46
 
47
- 装配期从顶层 session agent 的工具清单中**物理移除**执行工具(默认 bash/edit/write,可用 `gatedTools` 覆盖)——模型看不见这些工具,自然无法调用,编排从劝告变成结构。`gateMode: 'allowlist'` 反转语义:顶层 agent 只保留读 + 对话 + 展示 + goal/编排动词(内置 DEFAULT_GATE_ALLOWLIST,可 `gateAllowlist`/`gateAllowlistExtra` 覆盖),针对变更类工具的长尾(farm_*/taskboard_*/schedule_*……)。task/verify/triage 子 agent 从不在门禁范围内,保留全部工具。
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
- - **硬门相关**(`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`,非 volatile):不进入卡片,请在 profile 配置文档中维护(保存触发插件重载)。
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`、`hardGate`、`gateMode`、`gatedTools`、`gateAllowlist*`、`toolName`、`implicitTrigger`)保存触发插件行 remount,需宿主重载完成。且宿主设置表单只服务也只接受 volatile 路径——运行中宿主的 schema 过旧时,volatile 写也会被拒(重启即愈)。
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),也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。回车或移开焦点即保存。',
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': '可从下拉选择当前系统模型,也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。triage 角色读 task/verify 结果输出本轮收敛决策(met/continue/human-input 等),quick 轮不启用。回车或移开焦点即保存。',
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), or type "provider/model" or a bare model name; empty = inherit the session agent. Enter or blur saves.',
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, or 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.',
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 six:
548
- // allGoalsMode / taskAgentModel / verifyAgentModel / triageAgentModel /
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
- value: drafts[spec.field] !== undefined
811
- ? drafts[spec.field]
812
- : configuredValue,
813
- onChange: (e) => setDraft(spec.field, e && e.target ? String(e.target.value) : ''),
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') commitField(spec);
817
- if (e.key === 'Escape') { clearDraft(spec.field); setInvalid(''); }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.8.4",
3
+ "version": "0.8.10",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
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
- brief: state.brief,
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
- brief: state.brief,
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. hardGate and the gate fields stay non-volatile: the panel
128
- // cannot write them (they change structural wiring — tool stripping listener —
129
- // so they belong in the profile config document instead).
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
- /** Hard orchestration gate (default false). */
186
- hardGate: z.boolean().default(false).description('硬编排门:装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
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
- /** Gate semantics. */
190
- gateMode: z.union(['denylist', 'allowlist'] as const).default('denylist').description('门语义:denylist 隐藏 gatedTools;allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身。'),
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
- const HARD_GATE_GUIDANCE = `
518
- HARD GATE ACTIVE: your tool list is restricted to reading, searching, conversation, presentation and
519
- goal/orchestration verbs — every tool that CHANGES the world (edit/write/bash, service lifecycle,
520
- board/knowledge/schedule writes, spawning other workers, ...) is STRUCTURALLY unavailable to you.
521
- Answering a read-only question directly is fine and needs no goal; but the moment the user asks for a
522
- change — however small — that is your signal to delegate: create a cybernetics goal (action 'start'
523
- with title+brief) and let a task agent execute it. Never tell the user a change is "too small for the
524
- loop": the loop is the only way changes get done. If you look for a tool to perform a change and do
525
- not find it, that is the gate working as intended — delegate instead.`
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
- const hardGate = config.hardGate === true
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 : '') + (hardGate ? HARD_GATE_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
- if (hardGate) {
570
- // The hard gate: a system-prompt/assemble waterfall listener that strips
571
- // execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
572
- // (task/verify/triage) and team teammates are never in agents.roots(),
573
- // so their assemblies pass through untouched with full tool access.
574
- const allowlistMode = config.gateMode === 'allowlist'
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
- disposers.push(() => listenerDisposer?.())
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
- disposers.push(() => preDisposer?.())
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