Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/compaction.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/sessions/request-inspection.ts
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/README.i18n.yaml
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/fs/tool-str-replace-editor/tests/tools.spec.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.md
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
#	packages/pty/tool-bash-persistent/tests/tools.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/tests/tui.spec.ts
#	packages/ui/user-approval/src/index.ts
#	packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
_Kerman
2026-07-31 22:16:40 +08:00
1010 changed files with 105572 additions and 5504 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7
README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e
README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3
README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c

View File

@@ -10,16 +10,17 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.

View File

@@ -10,16 +10,17 @@
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。

View File

@@ -1,8 +1,8 @@
/**
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
* against a leaf `cordis.yml` until the whole tree has settled.
* optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
* @module @deepseek-ai/dsh-app-boot
*/
@@ -12,11 +12,18 @@ import { basename, dirname, join, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module 'cordis' {
interface Context {
/** Harness-home path resolver available to Loader `!!js` config expressions. */
dshHomePath?: typeof dshHomePath
}
}
/**
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
@@ -60,16 +67,12 @@ export function loadEnv(
/** File inside the Harness home holding the personal loader overlay patches. */
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time. Personal
// patches are parsed with the same schema so they may reference `process.env`.
// Load-only: this schema never dumps, so no `predicate`/`represent`.
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
// The include's YAML dialect (`!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time), imported
// from the include itself so patch parsing and config dumping can never drift
// from what the include mounts. Personal patches share it so they may
// reference `process.env`.
const personalPatchesSchema = entryListSchema
/**
* Load the optional personal overlay patches (`config.yaml` under the Harness
@@ -149,6 +152,141 @@ function parsePatchList(
return parsed as PatchOptions[]
}
/** One overlay patch list with the label provenance comments print for it. */
export interface ConfigDumpLayer {
/** Source name shown in provenance comments (a file basename or path). */
label: string
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */
patches: PatchOptions[]
}
/**
* Compose the effective entry list exactly as `boot()` would mount it: parse
* the base config file with the include's entry-list dialect, apply every
* layer's patches as ONE flattened list through the include's own patch
* algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
* even patch-visibility corner cases (a later layer targeting a group child a
* plain config replacement introduced, which the single-pass id index never
* sees) compose identically — then render the result as YAML in the same
* dialect (`!!js` expressions print verbatim, unevaluated).
*
* Every run of rows with the same provenance is preceded by a `# ==` comment
* naming the file that contributed the rows and any layers that patched them,
* so the output stays a loadable YAML document while showing which section
* comes from which file. Provenance is derived from single-call prefix
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
* only rewrites rows in place or appends, so a top-level index identifies one
* row across snapshots, and a layer whose addition changes the row (config
* replacement, disable, group insert) is listed as having patched it.
*
* A patch that matches no row is reported through `warn` with its layer
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
* see an identical preceding state in every snapshot that includes them, so
* each snapshot's warning list extends the previous one and the new tail
* belongs to the added layer.
* @param binName - the diagnostic prefix on read/parse errors.
* @param absoluteConfigPath - the base config file `boot()` would include.
* @param layers - overlay layers in application order (later wins).
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
* @returns the composed entry list rendered as a YAML document with
* provenance comment separators.
*/
export function renderConfigDump(
binName: string,
absoluteConfigPath: string,
layers: ConfigDumpLayer[],
warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
): string {
let content: string
try {
content = readFileSync(absoluteConfigPath, 'utf8')
} catch (error) {
throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
}
let parsed: unknown
try {
parsed = yaml.load(content, { schema: entryListSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
}
const baseLabel = basename(absoluteConfigPath)
// The YAML boundary yields untyped rows; the include validates entry shape
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
// here is structural trust in the same file `boot()` would include.
const base = parsed as Parameters<typeof applyEntryPatches>[0]
// snapshot_k = ONE application of layers 1..k flattened — boot's exact call
// shape for that prefix. snapshot_N is therefore the mounted composition.
// The patches are cloned per call: applyEntryPatches detaches the entry
// list but pushes `insert` rows by reference from the patch list, so
// sharing patch objects across snapshot calls would leak a later
// snapshot's mutations into an earlier one's result.
const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
// The include logs through cordis's printf-style logger (`%C` = code); a
// dump has no logger, so substitute inline for a plain line.
let index = 0
warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
})
}
let previous = base
let previousWarnings: string[] = []
const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
let composed = base
for (let count = 1; count <= layers.length; count += 1) {
const layer = layers[count - 1]
/* v8 ignore next -- count iterates 1..length, so the slot exists */
if (layer === undefined) continue
const warnings: string[] = []
composed = snapshot(count, warnings)
for (const line of warnings.slice(previousWarnings.length)) {
warn(`${binName}: [${layer.label}] ${line}`)
}
const before = previous.map(entry => JSON.stringify(entry))
for (let index = 0; index < composed.length; index += 1) {
if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] })
else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label)
}
previous = composed
previousWarnings = warnings
}
return groupedDump(composed, provenance)
}
/** Render the composed rows grouped under one provenance comment per contiguous run. */
function groupedDump(
composed: readonly unknown[],
provenance: readonly { origin: string; patchedBy: string[] }[],
): string {
const lines: string[] = []
let currentLabel: string | undefined
let group: unknown[] = []
const flush = (): void => {
if (currentLabel === undefined || group.length === 0) return
lines.push(`# == ${currentLabel}`)
lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
group = []
}
for (let index = 0; index < composed.length; index += 1) {
const record = provenance[index]
/* v8 ignore next -- provenance is index-aligned with composed by construction */
if (record === undefined) continue
const label = record.patchedBy.length === 0
? record.origin
: `${record.origin}, patched by ${record.patchedBy.join(', ')}`
if (label !== currentLabel) {
flush()
currentLabel = label
}
group.push(composed[index])
}
flush()
return lines.join('\n') + '\n'
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.
@@ -160,16 +298,46 @@ export interface FailLoudProcess {
exit(code: number): void
}
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
// exact reasons already folded into the boot diagnostic visible through the
// next process rejection checkpoint so the process guard can coalesce them.
const assembledActivationRejections = new Map<unknown, number>()
function retainAssembledRejection(reason: unknown): void {
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
}
function releaseAssembledRejection(reason: unknown): void {
const count = assembledActivationRejections.get(reason)
if (count === undefined || count === 1) {
assembledActivationRejections.delete(reason)
} else {
assembledActivationRejections.set(reason, count - 1)
}
}
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
for (const reason of reasons) retainAssembledRejection(reason)
try {
await new Promise<void>(resolve => setImmediate(resolve))
} finally {
for (const reason of reasons) releaseAssembledRejection(reason)
}
}
/**
* Install before boot to turn a late unhandled plugin-init rejection into one
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
* the returned function removes the handler.
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
* {@link assertEntriesActivated} is ignored during its process checkpoint;
* every other rejection remains fatal. Stdout remains untouched for ACP; the
* returned function removes the handler.
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
if (assembledActivationRejections.has(err)) return
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
}
@@ -192,28 +360,64 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
/**
* Value mirrors used because Cordis's const enum has no runtime object to import.
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
* `packages/client/web/src/loader-status.ts`.
*/
const FIBER_PENDING = 0 as FiberState.PENDING
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_FAILED = 3 as FiberState.FAILED
/** Render a thrown plugin value without discarding an Error's original stack. */
function formatActivationError(error: unknown): string {
return error instanceof Error ? error.stack ?? error.message : String(error)
}
/**
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
* @param ctx - The settled application root.
* @param binName - Diagnostic prefix.
* Reject a settled Loader tree when an enabled entry failed or remains inactive.
* Plugin failures include the original thrown stack; pending entries name their
* unresolved services because no plugin error exists for that state. Active
* entries require no further wait; only failed fibers are awaited to recover
* their private rejection reason.
* @param ctx - the settled context whose Loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
* @returns nothing when every enabled entry is active.
* @throws after one process rejection checkpoint when an entry failed to
* import, rejected during activation, or did not become active.
*/
export function assertEntriesActive(ctx: Context, binName: string): void {
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
assertEntriesLoaded(ctx, binName)
const failures: string[] = []
const rejectionReasons: unknown[] = []
for (const entry of ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
if (entry.fiber.state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
const fiber = entry.fiber
if (fiber === undefined || entry.disabled) continue
const state = fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_FAILED) {
try {
await fiber.await()
} catch (error) {
rejectionReasons.push(error)
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
}
continue
}
if (state === FIBER_PENDING) {
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
const subject = missing.length === 1 ? 'service' : 'services'
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
if (rejectionReasons.length > 0) {
await observeLoaderRejectionCheckpoint(rejectionReasons)
}
const noun = failures.length === 1 ? 'entry' : 'entries'
throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
}
}
@@ -225,9 +429,13 @@ export function assertEntriesActive(ctx: Context, binName: string): void {
* bootstrap include is therefore statically imported and mounted as the
* `cordis:include` builtin, loading through the ambient module pipeline
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
* config-relative. A missing fiber rejects here; a later init rejection is
* handled by {@link installFailLoud}. Built bins need the Loader's native
* helper for bare plugin specifiers; relative specifiers do not.
* config-relative. The package build embeds Include while leaving Loader
* external, so the built include tree and host share one Loader peer. A
* missing fiber rejects here; a later init rejection is rethrown with its
* original stack by {@link assertEntriesActivated}; later unhandled
* rejections remain covered by {@link installFailLoud}. Built bins need the
* Loader's native helper for bare plugin specifiers; relative specifiers do
* not.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
@@ -245,6 +453,7 @@ export async function boot(
): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await prepare?.(ctx)
@@ -259,12 +468,11 @@ export async function boot(
// A surface can finish and dispose the whole tree while that await is still
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
// typed before the last entry settles tears the context down under us. The
// Loader service goes with it, and both assertions below describe a live
// Loader service goes with it, and the activation audit describes a live
// tree — reading `ctx.loader` here would throw a TypeError over an app that
// exited exactly as asked.
if (ctx.get('loader') === undefined) return ctx
assertEntriesLoaded(ctx, binName)
assertEntriesActive(ctx, binName)
await assertEntriesActivated(ctx, binName)
return ctx
}

View File

@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
it('does not report an activation rejection shared by entries in the boot audit', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('assembled activation failure')
const audit = assertEntriesActivated({
loader: {
entries: () => ['broken-a', 'broken-b'].map(name => ({
options: { name },
fiber: {
state: 3,
inject: {},
ctx: { get: () => undefined },
await: async () => { throw error },
},
})),
},
} as unknown as Context, NAME)
await Promise.resolve()
await Promise.resolve()
proc.handlers[0]!(error)
expect(proc.written).toEqual([])
expect(proc.exits).toEqual([])
await expect(audit).rejects.toThrow('assembled activation failure')
proc.handlers[0]!(error)
expect(proc.exits).toEqual([1])
})
})
describe('assertEntriesLoaded', () => {
@@ -157,6 +184,97 @@ describe('assertEntriesLoaded', () => {
})
})
describe('assertEntriesActivated', () => {
interface FakeFiber {
state: number
inject: Record<string, unknown>
ctx: { get(name: string): unknown }
await(): Promise<unknown>
}
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
loader: { entries: () => entries },
}) as unknown as Context
const fiber = (
state: number,
error?: unknown,
inject: Record<string, unknown> = {},
services: string[] = [],
): FakeFiber => ({
state,
inject,
ctx: { get: name => services.includes(name) ? {} : undefined },
await: error === undefined ? async () => undefined : async () => { throw error },
})
it('passes active entries and ignores disabled entries', async () => {
let awaitCalls = 0
const active = fiber(2)
active.await = async () => {
awaitCalls++
return undefined
}
const disabled = fiber(3, new Error('disabled failure'))
disabled.await = async () => {
awaitCalls++
throw new Error('disabled failure')
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: active, options: { name: 'active' } },
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
]), NAME)).resolves.toBeUndefined()
expect(awaitCalls).toBe(0)
})
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
const original = new Error('actual plugin failure')
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
})
it('formats stackless and non-Error activation failures', async () => {
const stackless = new Error('stackless failure')
delete (stackless as { stack?: string }).stack
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
})
it('reports unresolved services for pending entries', async () => {
let awaitCalls = 0
const expected = [
`${NAME}: 3 entries did not activate`,
'waiting: pending (waiting for services: missingA, missingB)',
'single-wait: pending (waiting for service: missing)',
'unknown-wait: pending (waiting for services: unknown)',
].join('\n')
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
const singleWait = fiber(0, undefined, { missing: {} })
const unknownWait = fiber(0)
for (const item of [waiting, singleWait, unknownWait]) {
item.await = async () => {
awaitCalls++
return undefined
}
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: waiting, options: { name: 'waiting' } },
{ fiber: singleWait, options: { name: 'single-wait' } },
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
]), NAME)).rejects.toThrow(expected)
expect(awaitCalls).toBe(0)
})
it('retains the numeric diagnostic for a settled unexpected state', async () => {
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(4), options: { name: 'disposed' } },
]), NAME)).rejects.toThrow('disposed: fiber state 4')
})
})
describe('loadOverlayPatches', () => {
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
const dir = tmp()
@@ -207,6 +325,34 @@ describe('boot', () => {
}
})
it('exposes dshHomePath to Loader config expressions', async () => {
const dir = tmp()
const dshHome = join(dir, 'home')
vi.stubEnv('DSH_HOME', dshHome)
writeFileSync(join(dir, 'capture.mjs'), [
'export const name = "capture"',
'export function apply(ctx, config) {',
' ctx.provide("capturedPath", config.path)',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), [
'- id: capture',
' name: ./capture.mjs',
' config:',
" path: !!js dshHomePath('sessions')",
'',
].join('\n'))
let ctx: Context | undefined
try {
ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
} finally {
await ctx?.fiber.dispose()
vi.unstubAllEnvs()
}
})
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
// fiber, which lands while boot() is still awaiting the Loader whenever the
@@ -232,31 +378,14 @@ describe('boot', () => {
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
it('rejects a settled tree with a pending inject and names every missing service', async () => {
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
})
it('uses singular diagnostics for one missing pending dependency', () => {
const ctx = {
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
})
it('reports unknown pending dependencies and unexpected fiber states', () => {
const entries = [
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
]
const ctx = {
loader: { entries: () => entries },
get: () => undefined,
} as unknown as Context
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
`${NAME}: 1 entry did not activate`,
'./waiting.mjs: pending (waiting for service: neverProvided)',
].join('\n'))
})
})

View File

@@ -0,0 +1,187 @@
/**
* `renderConfigDump` behavior: the offline composition must equal what
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
* expressions verbatim, separate provenance runs with comment lines while
* staying one loadable YAML document, and report skipped patches through
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
* shared overlay whose row exists only on another surface.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@cordisjs/plugin-include'
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
function writeBase(dir: string): string {
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: shared',
' name: ./noop.mjs',
' config:',
' value: base',
' key: !!js process.env.DSH_DUMP_SPEC',
'- id: untouched',
' name: ./noop.mjs',
'',
].join('\n'))
return base
}
describe('renderConfigDump', () => {
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
const dir = tmp()
const base = writeBase(dir)
const surface = join(dir, 'surface.yml')
writeFileSync(surface, [
'- id: shared',
' config:',
' value: surface',
' key: !!js process.env.DSH_DUMP_SPEC',
'- insert:',
' - id: surface-extra',
' name: ./noop.mjs',
'',
].join('\n'))
const personal = join(dir, 'personal.yml')
writeFileSync(personal, [
'- id: surface-extra',
' config:',
' value: personal',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
], () => {})
// Comments do not break loadability: the dump parses as one document
// equal to what boot() would mount.
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
id: string
config?: Record<string, unknown>
}[]
expect(parsed).toEqual([
{
id: 'shared',
name: './noop.mjs',
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
},
{ id: 'untouched', name: './noop.mjs' },
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
])
// Unevaluated: the expression text round-trips as a !!js scalar.
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
// Provenance separators: origin file, plus every layer that changed the
// row; an inserted row carries the inserting layer as its origin.
expect(dump).toContain('# == base.yml, patched by surface.yml')
expect(dump).toContain('# == base.yml\n- id: untouched')
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
})
it('groups contiguous same-provenance rows under one separator', () => {
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: a',
' name: ./noop.mjs',
'- id: b',
' name: ./noop.mjs',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [], () => {})
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
expect(dump).toContain('# == base.yml\n- id: a')
})
it('composes all layers as one flattened patch list, exactly like boot()', () => {
// boot() flattens every layer into ONE applyEntryPatches call, whose id
// index sees inserted rows but NOT children introduced by a plain group
// `config` replacement. A per-layer composition would rebuild the index
// between layers and let the second layer patch that child — a tree the
// real boot never mounts. Pin the single-call semantics: the child patch
// is skipped (with the layer-labeled warning), matching boot.
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: g',
' name: ./group.mjs',
' group: true',
' config: []',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(NAME, base, [
{
label: 'a.yml',
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
},
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
], line => void warnings.push(line))
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
config?: { config?: { v?: number } }[]
}[]
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
// The skipped layer did not change the row, so it is not in provenance.
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
expect(dump).not.toContain('b.yml\n- id: g')
})
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
const dir = tmp()
const base = writeBase(dir)
const overlay = join(dir, 'overlay.yml')
writeFileSync(overlay, [
'- id: only-on-another-surface',
' config:',
' value: ignored',
'- id: shared',
' config:',
' value: patched',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(
NAME, base,
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
line => void warnings.push(line),
)
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
expect(parsed[0]?.config?.value).toBe('patched')
})
it('defaults its warn sink to one stderr line per skipped patch', () => {
const dir = tmp()
const base = writeBase(dir)
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
} finally {
write.mockRestore()
}
})
it('fails loud on a missing, unparsable, or non-array base config', () => {
const dir = tmp()
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to read config `))
const invalid = join(dir, 'invalid.yml')
writeFileSync(invalid, 'invalid: [unclosed\n')
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, 'id: not-a-list\n')
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
.toThrow('must be a top-level YAML array of entries')
})
})

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'tsdown'
/**
* Embed Include while keeping Loader external so the built include tree and
* app host bind to one Loader peer.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
deps: {
alwaysBundle: ['@cordisjs/plugin-include'],
},
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/permission/README.md
README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4
README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa
README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a
README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184

View File

@@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
@@ -23,3 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.

View File

@@ -6,7 +6,9 @@
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
@@ -23,3 +25,4 @@
- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。

View File

@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -58,6 +59,7 @@
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a
import type {} from '@deepseek-ai/dsh-bash'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-commands'
@@ -68,6 +69,9 @@ export interface PresetSpec {
*/
export const CUSTOM_PRESET = 'custom'
/** Settings namespace carrying the default for future sessions. */
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
/**
* Fold the last selected preset from the durable log; replay needs no catch-up
* state.
@@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
return state
}
/** The {@link PermissionService} config: the deployment's preset table. */
/** User setting resolved when a new session receives its initial permission. */
export interface PermissionSettings {
/** Preset pinned into a newly created session. */
defaultPreset: string
}
/** The {@link PermissionService} config: preset table and composition default. */
export interface Config {
/**
* The preset table: name → knob bundle. Defaults to `workspace-write`
@@ -134,6 +144,11 @@ export interface Config {
* never). The name `custom` is reserved for the derived not-a-preset state.
*/
presets?: Record<string, PresetSpec>
/**
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
/**
@@ -159,11 +174,13 @@ export class PermissionService extends Service {
name: 'danger-full-access', description: 'Full file access without approval prompts.',
},
}),
defaultPreset: z.string(),
})
static inject = ['bash', 'approval']
static inject = ['bash', 'approval', 'sessions']
private readonly presets: Record<string, PresetSpec>
private defaultSettings: () => PermissionSettings
constructor(ctx: Context, config: Config) {
super(ctx, 'permission')
@@ -175,6 +192,37 @@ export class PermissionService extends Service {
if (ctx.bash.sandboxMode === undefined) {
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
}
const inferredDefault = this.derive(EMPTY_KNOBS)
const defaultPreset = config.defaultPreset ?? inferredDefault
if (defaultPreset === CUSTOM_PRESET) {
throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly')
}
this.resolve(defaultPreset)
const baseSettings: PermissionSettings = { defaultPreset }
this.defaultSettings = () => baseSettings
const presetChoices = this.names.map((name) => {
const choice = z.const(name)
const label = this.presets[name]?.name
return label === undefined ? choice : choice.description(label)
})
const settingsSchema: z<PermissionSettings> = z.object({
defaultPreset: z.union(presetChoices).required(),
})
installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, {
setSource: (current) => {
this.defaultSettings = current
},
// The source thunk reads the latest scope snapshot at session creation;
// no process-level registration needs replacement on change.
onChange: () => {},
})
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
for (const session of ctx.sessions.list()) {
this.pinInitialPermission(session)
}
// The permissions projection unit: fold the three whole-value knob
// events; view derives the select over the composition defaults this
@@ -237,6 +285,15 @@ export class PermissionService extends Service {
return Object.keys(this.presets)
}
/**
* The preset currently selected as the default for future sessions.
* @returns the resolved settings value, or the composition default without
* a mounted settings provider.
*/
get defaultPreset(): string {
return this.defaultSettings().defaultPreset
}
/**
* Resolve the preset matching the effective knob values. A still-matching
* last selection wins shared-bundle ties; otherwise the first table match
@@ -328,6 +385,44 @@ export class PermissionService extends Service {
setApprovalPolicy(session, spec.approval)
}
}
/**
* Fill every missing permission fact before a session is published. A
* genuinely fresh session uses the current user default; seeded or partially
* initialized sessions preserve their effective knob values and only gain
* the missing durable facts.
*/
private pinInitialPermission(session: Session): void {
const events = session.events
const selected = effectivePermissionPreset(events)
const sandbox = effectiveSandboxMode(events)
const approval = effectiveApprovalPolicy(events)
const seeded = events.some(event => event.type === 'session/end-seed')
if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
const name = this.defaultPreset
const spec = this.resolve(name)
session.append('permission/preset', { preset: name })
setSandboxMode(session, spec.sandbox)
setApprovalPolicy(session, spec.approval)
return
}
const state: KnobState = {
preset: selected ?? null,
sandbox: sandbox ?? null,
approval: approval ?? null,
}
const effective = this.derive(state)
if (selected === undefined && effective !== CUSTOM_PRESET) {
session.append('permission/preset', { preset: effective })
}
if (sandbox === undefined) {
setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
}
if (approval === undefined) {
setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
}
}
}
export default PermissionService

View File

@@ -1,10 +1,29 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
import PermissionService, {
CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE,
} from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
/** Writable memory provider for the permission/settings lifecycle specs. */
class MemorySettings extends Settings {
readonly doc: Record<string, unknown> = {}
readonly writable = true
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc[ns] = structuredClone(section)
return Promise.resolve()
}
}
async function mounted(options: {
config?: Config
@@ -12,6 +31,7 @@ async function mounted(options: {
approvalDefault?: ApprovalPolicy | undefined
} = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
@@ -27,6 +47,23 @@ function freshSession(id: string): Session {
return new Session(SessionId(id))
}
async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(MemorySettings)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', {
config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' },
})
await ctx.plugin(PermissionService, {})
return ctx
}
describe('effectivePermissionPreset', () => {
it('folds to the last event, or undefined without one', () => {
const session = freshSession('sess-fold')
@@ -66,8 +103,11 @@ describe('PermissionService', () => {
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
})
it('composition defaults outside the table derive custom at zero events', async () => {
const ctx = await mounted({ approvalDefault: 'never' })
it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => {
const ctx = await mounted({
approvalDefault: 'never',
config: { defaultPreset: 'workspace-write' },
})
const session = freshSession('sess-defaults-custom')
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
})
@@ -138,6 +178,11 @@ describe('PermissionService', () => {
.rejects.toThrow(/reserved for the derived not-a-preset state/)
})
it('requires an explicit default when composition defaults match no preset', async () => {
await expect(mounted({ approvalDefault: 'never' }))
.rejects.toThrow(/configure defaultPreset explicitly/)
})
it('reads a schema-less approval stand-in as the ask default', async () => {
const ctx = await mounted({ approvalDefault: undefined })
const session = freshSession('sess-standin')
@@ -146,3 +191,111 @@ describe('PermissionService', () => {
expect(ctx.permission.current(session.events)).toBe('workspace-write')
})
})
describe('new-session default', () => {
it('pins the current setting into each new session without changing earlier sessions', async () => {
const ctx = await mountedStore()
const first = ctx.sessions.create(SessionId('first'))
expect(first.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'workspace-write' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
])
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
expect(ctx.permission.defaultPreset).toBe('danger-full-access')
const second = ctx.sessions.create(SessionId('second'))
expect(ctx.permission.current(first.events)).toBe('workspace-write')
expect(ctx.permission.current(second.events)).toBe('danger-full-access')
expect(second.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('preserves a seeded legacy session instead of applying the latest user default', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
const legacy = freshSession('legacy-source')
legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('preserves composition defaults when an empty stored session resumes', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.map(event => event.type)).toEqual([
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('pins sessions that already exist when the service remounts', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
const existing = ctx.sessions.create(SessionId('existing-before-permission'))
expect(existing.events).toEqual([])
await ctx.plugin(PermissionService, {})
expect(existing.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
expect(ctx.permission.current(existing.events)).toBe('workspace-write')
})
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
const ctx = await mountedStore()
const partial = freshSession('partial-source')
partial.append('sandbox/mode', { mode: 'workspace-write' })
partial.append('approval/policy', { policy: 'ask' })
const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events })
expect(resumed.events.at(-1)).toMatchObject({
type: 'permission/preset',
data: { preset: 'workspace-write' },
})
const custom = freshSession('custom-source')
custom.append('sandbox/mode', { mode: 'read-only' })
custom.append('approval/policy', { policy: 'never' })
const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events })
expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET)
expect(unmatched.events.at(-1)?.type).toBe('session/end-seed')
})
it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => {
const ctx = await mountedStore({ approvalDefault: undefined })
const partial = freshSession('approval-fallback-source')
partial.append('sandbox/mode', { mode: 'workspace-write' })
const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events })
expect(resumed.events.at(-1)).toMatchObject({
type: 'approval/policy',
data: { policy: 'ask' },
})
})
it('rejects a stored default outside the configured preset table', async () => {
const ctx = await mountedStore()
await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'missing',
})).rejects.toThrow()
expect(ctx.permission.defaultPreset).toBe('workspace-write')
})
})

View File

@@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise<Agent> {
}
describe('permissions projection unit', () => {
it('serves the composition-default select at zero events', async () => {
it('serves the pinned new-session default select', async () => {
const { ctx, session } = await harness()
const value = ctx.sessionProjections.snapshot(session).values.permissions
expect(value).toMatchObject({ currentValue: 'workspace-write' })
@@ -103,12 +103,14 @@ describe('/permission command', () => {
kind: 'success',
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1)
})
it('rejects an unknown preset without touching the log', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const before = session.events.filter(event =>
event.type !== 'command/run' && event.type !== 'command/done')
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
// The error text carries the same no-self-labelling rule as the success
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
@@ -117,6 +119,7 @@ describe('/permission command', () => {
kind: 'error',
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
expect(session.events.filter(event =>
event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before)
})
})

View File

@@ -38,6 +38,9 @@
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../settings/settings"
},
{
"path": "../commands"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 3b9153c42885dfb29821ecf452811c1bb15446f2
README.zh.md: 1d6f7bcedcd4fa8b9e3e87df137ba59f8f535666
README.md: dccc41299292c9a5f1a5bf65a311e2ccb6bf8979
README.zh.md: 26eeb33d993187bd8b8134d65e64a15727180795

View File

@@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
@@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation; both enter the inbox and pass through `agent/pre-step` after the loop claims their batch.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
@@ -40,7 +40,7 @@ Selection repeats those checks and requires the current agent to be idle before
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh experimental-upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
## Config
@@ -79,9 +79,9 @@ Startup fails before mounting when either process stream is not a TTY. The compo
## Color
Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade.
Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike. The startup banner gradient and the official mark's exact `#4D6BFE` ink are the two deliberate truecolor brand exceptions. Body text keeps the terminal's default foreground rather than a fixed shade.
There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.

View File

@@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
@@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`;二者都会进入 inbox,并在循环领取相应批次后经过 `agent/pre-step`。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
@@ -40,7 +40,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
## 配置
@@ -79,9 +79,9 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
## 颜色
TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。
TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。

View File

@@ -8,7 +8,7 @@
*/
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { TuiOverlaySession } from '../extension/types.ts'
import { displayText } from '../components/text.ts'
import {
@@ -37,6 +37,8 @@ export interface ModelController {
resetContextResolution(): void
/** Forget the tracked selector overlay (shutdown). */
clearOverlay(): void
/** Remove the adapter-registration listener (channel detach). */
detach(): void
}
type ContextResolution =
@@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
let modelOverlay: TuiOverlaySession | undefined
let modelCommands = Promise.resolve()
// A route whose adapter has not registered yet. Loader activation order is
// service-driven, so the TUI can mount before a configured adapter plugin
// activates; that transient NO_ADAPTER is not an error — the resolution
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
let awaitingAdapter = false
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
awaitingAdapter = false
const resolution: Promise<ContextResolution> = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
@@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
awaitingAdapter = true
return
}
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
@@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
deps.requestRender()
})
}
// The wait cannot go stale against `target.current`: every target change
// re-enters resolveContextWindow, which clears it. A commit that still
// lacks the route parks the resolution again rather than erroring, so
// unrelated topology changes stay silent. The disposer rides the channel's
// detachListeners() through detach(), matching the sibling listeners.
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
if (deps.isDisposed() || !awaitingAdapter) return
resolveContextWindow(target.current)
})
resolveContextWindow(target.current)
const selectModel = (
@@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
clearOverlay(): void {
modelOverlay = undefined
},
detach(): void {
disposeAdapterListener()
},
}
}

View File

@@ -1,8 +1,8 @@
/**
* Per-step timing model and running-status glyph animation for the terminal
* Per-step timing model and prompt-status glyph animation for the terminal
* front door. Timing buckets are replayed from the session event stream; the
* running glyph fades in on turn start, throbs while the turn runs, and fades
* out on turn end.
* active glyph fades in when work starts, throbs while work runs, and fades out
* when it ends.
* @module @deepseek-ai/dsh-tui/chat/timing
*/
@@ -10,25 +10,25 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Palette } from '../components/theme.ts'
/**
* Render cadence of the running prompt while active, and while the glyph fades
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
* Render cadence of the status prompt while active, and while the glyph fades
* out after work ends. ~20 fps so the truecolor glyph fade reads smoothly;
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
* changed terminal cells are re-emitted, so the faster tick stays cheap.
*/
export const STATUS_ANIMATION_INTERVAL_MS = 50
/**
* Milliseconds over which the running glyph fades in when a turn starts and
* fades out after it ends. The fade is an envelope over the running pulse:
* Milliseconds over which the status glyph fades in when work starts and fades
* out after it ends. The fade is an envelope over the active pulse:
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
*/
export const STATUS_FADE_MS = 300
/** Milliseconds for one full brightness throb of the running glyph. */
/** Milliseconds for one full brightness throb of the active status glyph. */
export const STATUS_PULSE_PERIOD_MS = 1400
/**
* Brightness floor of the running throb, as a fraction of the settled gray. At
* Brightness floor of the status throb, as a fraction of the settled gray. At
* 0 the pulse swells from the near-background trough up to full and back. The
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
* cosine breathes symmetrically bold→dim→bold.
@@ -36,7 +36,7 @@ export const STATUS_PULSE_PERIOD_MS = 1400
export const STATUS_PULSE_FLOOR = 0
/**
* Muted-gray foreground the truecolor running glyph fades through, from the
* Muted-gray foreground the truecolor status glyph fades through, from the
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
* appearing rather than a colored indicator. Foreground-only, matching the
@@ -185,6 +185,9 @@ export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
tools: '⚙',
}
/** Status glyph for a live standalone compaction bracket. */
const COMPACTING_GLYPH = '⊙'
/**
* Derive the currently open step's active timing bucket, or `undefined` when no
* step is open. The open step is the last `step/start` with no later matching
@@ -219,25 +222,32 @@ export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | u
}
/**
* The running agent's phase glyph, or `undefined` when idle. A running turn
* with no open step falls back to the pre-first-token wait so a glyph is always
* available while the agent works; it fades in on turn start, throbs while the
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
* The active status glyph, or `undefined` when idle. A running turn takes
* precedence over standalone compaction and falls back to the pre-first-token
* wait when no step is open. The caller applies the shared fade and throb
* animation (see {@link fadeGlyph}).
* @param events - Session events to derive the phase from.
* @param running - Whether the agent is currently running.
* @returns The phase glyph, or `undefined` when idle.
* @param compacting - Whether a live standalone compaction bracket is open.
* @returns The active status glyph, or `undefined` when idle.
*/
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
if (!running) return undefined
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
export function runningPhaseGlyph(
events: readonly SessionEvent[],
running: boolean,
compacting: boolean,
): string | undefined {
if (running) {
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
}
return compacting ? COMPACTING_GLYPH : undefined
}
/**
* The running throb's brightness at continuous clock `nowMs`: a cosine between
* The status throb's brightness at continuous clock `nowMs`: a cosine between
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
* fade envelope, which alone drives appear/disappear at turn boundaries.
* fade envelope, which alone drives appear/disappear at work boundaries.
*
* @param nowMs - Monotonic render clock in milliseconds.
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
@@ -249,14 +259,14 @@ export function pulseLevel(nowMs: number): number {
}
/**
* One frame of the running glyph at fade `opacity` (0 = near-background trough
* One frame of the status glyph at fade `opacity` (0 = near-background trough
* gray, 1 = settled dim gray). The character and its width never change — only
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
* the caret dimly breathing, never a colored indicator.
*
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
* and the running throb render as a smooth, symmetric brightness swing with no
* and the status throb render as a smooth, symmetric brightness swing with no
* hard cutoff to clip the trough into a blank. Without truecolor there is no
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
* shows the glyph in the palette's muted role or leaves a blank column — a
@@ -264,7 +274,7 @@ export function pulseLevel(nowMs: number): number {
* no throb-driven blink. With color off entirely a visible glyph is bare,
* holding the caret column on a monochrome terminal.
*
* @param glyph - The phase glyph to paint.
* @param glyph - The status glyph to paint.
* @param palette - Active palette supplying the muted (dim gray) role.
* @param colorEnabled - Whether ANSI is emitted at all.
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.

View File

@@ -46,6 +46,8 @@ export type AttributeRole = <T extends string>(text: T) => T
*/
export interface Palette {
accent: ColorRole
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
brand: ColorRole
/** The terminal's own default foreground; still a color, so it does not stack. */
text: ColorRole
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
@@ -63,7 +65,7 @@ export interface Palette {
}
/** Names of the palette's color roles, in the order `/palette` prints them. */
export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', 'code', 'success', 'warning', 'error'] as const
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
@@ -86,8 +88,9 @@ export interface RoleSpec {
*
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
* those to the user's active theme, so the TUI stays legible on any background;
* a fixed 24-bit color would not. The brand gradient is the one deliberate
* exception ({@link gradientText}).
* a fixed 24-bit color would not. The startup gradient and exact official mark
* color are the two deliberate brand exceptions ({@link gradientText},
* {@link brandText}).
*
* @param scheme - Active terminal color scheme; only `code` differs between them.
* @returns The SGR spec for every color and attribute role.
@@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): {
// prominent text on screen.
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
// ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34
// (blue) which is legible on both light and dark schemes.
code: scheme === 'light'
@@ -168,6 +172,19 @@ const BRAND_GRADIENT = [
[36, 152, 255], // #2498FF
] as const
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
/**
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
* @param text - Static brand text or raster cells.
* @returns text wrapped in the official truecolor foreground and a foreground reset.
*/
export function brandText(text: string): string {
const [r, g, b] = DEEPSEEK_BRAND_RGB
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
}
/**
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
* interpolation across its stops.
@@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] {
* @returns `text` wrapped in truecolor SGR foreground codes.
*/
export function gradientText(text: string): string {
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
// samples exactly one color per visible letter.
const last = Math.max(1, text.length - 1)
const glyphs = Array.from(text)
const last = Math.max(1, glyphs.length - 1)
let painted = ''
for (let index = 0; index < text.length; index += 1) {
for (let index = 0; index < glyphs.length; index += 1) {
const [r, g, b] = brandColorAt(index / last)
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}`
}
return `${painted}\x1b[39m`
}

View File

@@ -52,15 +52,28 @@ function pretty(value: unknown): string {
return displayText(serialized ?? String(value))
}
/**
* A side's content lines under the terminator rule the Web DiffBlock also
* applies: empty text is zero lines (a full deletion's `newText`, a create's
* absent `oldText`), and a single trailing newline terminates the last line
* rather than adding an empty one. An interior blank line survives. Keeping the
* two front ends on the same rule holds their `+A -R` footers in step.
*/
function diffContentLines(text: string): string[] {
if (text === '') return []
const body = text.endsWith('\n') ? text.slice(0, -1) : text
return body.split('\n')
}
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
function diffLines(diff: FileDiff, palette: Palette): string[] {
// The card header is a fixed `Tool / <name>` frame that never names a file, so
// each hunk always carries its own path header (no redundancy to suppress).
const lines = [palette.bold(displayText(diff.path))]
if (diff.oldText !== null) {
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`))
for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`))
}
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
return lines
}
@@ -389,20 +402,26 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
// A generic card's own content, or a web card's fallback to the raw result
// content (the `web` view carries no `content` copy), both render as one dim
// Markdown block below, so links/lists/headings keep the unified dim styling
// rather than reading as bare text. Terminal and diff cards own their body
// A generic card's own content, a read card's `content` fallback (the
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
// read renders exactly as before the read card existed), or a search/web
// card's fallback to the raw result content (neither the `search` nor the
// `web` view carries a `content` copy), all render as one dim Markdown block
// below, so links/lists/headings keep the unified dim styling rather than
// reading as bare text. A search card thus stays byte-identical to the
// pre-search-card generic fallback. Terminal and diff cards own their body
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic'
const markdownContent = view.card === 'generic' || view.card === 'read'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
: view.card === 'search'
? this.result?.content
: undefined
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
? this.result?.content
: undefined
const unknownXml = this.definition === undefined && markdownContent !== undefined
? renderUnknownXml(
displayText(contentText(markdownContent)),
@@ -501,25 +520,31 @@ export class ToolCardComponent implements Component {
}
if (view.card === 'diff') {
// The header no longer names the file, so each diff keeps its own path
// header. A trailing footer summarizes the change (`+A -R · N file(s)`).
// header. A trailing footer summarizes the change (`+A -R · N file(s)`),
// on the same terminator rule and distinct-path count the Web DiffBlock
// uses, so the two front ends' footers agree.
let added = 0
let removed = 0
const paths = new Set<string>()
const hunks = view.diffs.flatMap((diff, index) => {
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
added += displayText(diff.newText).split('\n').length
paths.add(diff.path)
if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length
added += diffContentLines(displayText(diff.newText)).length
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
})
const files = view.diffs.length
const files = paths.size
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
// The web card carries no `content` copy, so a `web` result view falls back
// to the raw result content here (`view.card === 'generic'` narrows the
// generic union arm; a `web` card takes the same fallback, mirroring the
// `markdownContent` selection in render()).
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
// A generic or read card carries its own envelope-stripped `content`; a
// search or web card carries no `content` copy and falls back to the raw
// result content here. (Mirrors the `markdownContent` selection in render();
// a read card has no dedicated TUI rendering, so its `content` takes the same
// body path, keeping read output as it was before the read card existed, and
// a search card stays byte-identical to the pre-search-card fallback.)
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []
// The presenter title headlines the body now that the header is a fixed

View File

@@ -122,8 +122,8 @@ export interface Config extends TuiConfig {
/**
* Skill name auto-invoked as this session's first user turn, exactly as if
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
* turn to the user.
* skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent
* leaves the first turn to the user.
*/
initialSkill?: string
}

View File

@@ -37,6 +37,8 @@ export interface TuiFocusable {
export interface TuiTheme {
/** Render ordinary foreground text. */
readonly text: (value: string) => string
/** Render trusted static brand art with the host's configured brand treatment. */
readonly brand: (value: string) => string
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
readonly dim: (value: string) => string
/** Render the active accent role. */

View File

@@ -69,7 +69,7 @@ import type {
TuiTheme,
} from './extension/types.ts'
import { displayInlineText, displayText } from './components/text.ts'
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
import { contentText, parseArguments } from './components/content.ts'
import {
cacheHitRate,
@@ -80,6 +80,7 @@ import {
import {
fadeGlyph,
formatQueuedStatus,
formatStatusDuration,
openStepPhase,
openTurn,
pulseLevel,
@@ -226,9 +227,9 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade` guided-session
* entry. The launcher sets it only when minting a fresh session, so it never
* re-fires on a resumed one. Absent leaves the first turn to the user.
* turn with `/skill:<name>` — the `dsh migrate`/`dsh experimental-upgrade`
* guided-session entry. The launcher sets it only when minting a fresh session,
* so it never re-fires on a resumed one. Absent leaves the first turn to the user.
*/
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
@@ -329,6 +330,7 @@ export function createTuiChat(
})
editor.hintPrefix = initialInputPrompt
const todo = new TodoComponent(palette)
const compactionStatusLine = new Text('', 0, 0)
let showReasoning = resolved.showReasoning
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
// tool cards entirely, collapsed previews, expanded shows full bodies.
@@ -337,6 +339,14 @@ export function createTuiChat(
let completedStreaming: StreamingAssistantComponent | undefined
let runningStatus: RunningStatus | undefined
let fadingStatus: FadingStatus | undefined
/**
* Live standalone compaction observed by this process. Never derive this
* state from history: a resumed log may contain a stale orphaned start.
*/
let compacting: {
startedAt: number
timer: ReturnType<typeof setInterval>
} | undefined
// TUI steering submissions that the inbox has not yet claimed or discarded.
// Correlation ids avoid guessing whether a running-state submission actually
// joined steering or fell back to the queued-turn FIFO during turn close.
@@ -401,6 +411,7 @@ export function createTuiChat(
throw new Error('TUI prompt built-ins failed to initialize')
}
const updatePromptValues = (): void => {
const renderTime = now()
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
const rate = cacheHitRate(tokens)
@@ -414,23 +425,31 @@ export function createTuiChat(
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
symbolValue.set(palette.bold(palette.accent('dsh')))
compactionStatusLine.setText(compacting === undefined
? ''
: palette.dim(`Context being compacted ${formatStatusDuration(renderTime - compacting.startedAt)}`))
// `${indicator}` owns the caret column and its trailing gap before the
// cursor. The phase glyph replaces the `>` caret in place — same width
// every frame — fading in as a turn starts, throbbing while it runs, and
// fading out after it ends before the plain `>` returns. Only the gray
// cursor. The active status glyph replaces the `>` caret in place — same
// width every frame — fading in when work starts, throbbing while it runs,
// and fading out after it ends before the plain `>` returns. Only the gray
// brightness changes, so the cursor never shifts.
const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined)
const statusGlyph = runningPhaseGlyph(
agent.session.events,
runningStatus !== undefined,
compacting !== undefined,
)
// Remember the live phase glyph so the fade-out shows it, not the ttft
// fallback the derivation returns once the closing turn's step has ended.
if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph
// The fade envelope gates appear/disappear; the running throb breathes the
// glyph the whole turn. Truecolor opacity is envelope × throb; the
if (runningStatus !== undefined && statusGlyph !== undefined) runningStatus.lastGlyph = statusGlyph
// The fade envelope gates appear/disappear; the active throb breathes the
// glyph throughout the operation. Truecolor opacity is envelope × throb; the
// non-truecolor fallback keys visibility off the envelope alone, so the
// throb never blinks it. `envelope` clamps to [0, 1].
const envelope = runningStatus !== undefined && runningGlyph !== undefined
? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) }
const activeSince = runningStatus?.startedAt ?? compacting?.startedAt
const envelope = activeSince !== undefined && statusGlyph !== undefined
? { glyph: statusGlyph, level: Math.min(1, (renderTime - activeSince) / STATUS_FADE_MS) }
: fadingStatus !== undefined
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (renderTime - fadingStatus.endedAt) / STATUS_FADE_MS) }
: undefined
const caret = envelope === undefined
? palette.dim('>')
@@ -439,7 +458,7 @@ export function createTuiChat(
palette,
resolved.theme.color,
resolved.theme.color && resolved.theme.truecolor,
envelope.level * pulseLevel(now()),
envelope.level * pulseLevel(renderTime),
envelope.level >= 0.5,
)
indicatorValue.set(`${caret}${palette.dim(' ')}`)
@@ -454,6 +473,7 @@ export function createTuiChat(
ui.addChild(new Spacer(1))
todoContainer.addChild(todo)
ui.addChild(todoContainer)
ui.addChild(compactionStatusLine)
ui.addChild(promptContext)
ui.addChild(editor)
ui.setFocus(editor)
@@ -487,6 +507,9 @@ export function createTuiChat(
const extensionTheme: TuiTheme = Object.freeze({
text: (value: string) => palette.text(value),
brand: (value: string) => resolved.theme.color
? resolved.theme.truecolor ? brandText(value) : palette.brand(value)
: value,
dim: (value: string) => palette.dim(value),
accent: (value: string) => palette.accent(value),
success: (value: string) => palette.success(value),
@@ -538,8 +561,8 @@ export function createTuiChat(
requestRender()
}
/** Stop the running and fade-out timers and drop both states at once. */
const clearStatus = (): void => {
/** Stop the turn-phase running and fade-out timers and drop both states. */
const clearTurnStatus = (): void => {
if (runningStatus !== undefined) {
clearInterval(runningStatus.timer)
runningStatus = undefined
@@ -548,21 +571,30 @@ export function createTuiChat(
clearInterval(fadingStatus.timer)
fadingStatus = undefined
}
runtime.terminal.setProgress(false)
runtime.terminal.setProgress(compacting !== undefined)
}
/** Hard clear: drop every indicator, including a live compaction bracket. */
const clearStatus = (): void => {
if (compacting !== undefined) {
clearInterval(compacting.timer)
compacting = undefined
}
clearTurnStatus()
}
/**
* On the running → non-running edge, hand the last rendered glyph to a
* fade-out that re-renders until it settles on the `>` caret, then stops its
* own timer. A hard clear (teardown) skips this via {@link clearStatus}.
* Hand the last active glyph to a fade-out that re-renders until it settles
* on the `>` caret, then stops its own timer. A hard clear (teardown) skips
* this via {@link clearStatus}.
*/
const beginFadeOut = (glyph: string): void => {
clearStatus()
clearTurnStatus()
const fading: FadingStatus = {
glyph,
endedAt: now(),
timer: setInterval(() => {
if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus()
if (now() - fading.endedAt >= STATUS_FADE_MS) clearTurnStatus()
renderStatus()
}, STATUS_ANIMATION_INTERVAL_MS),
}
@@ -572,9 +604,9 @@ export function createTuiChat(
const setStatus = (status: AgentStatus): void => {
const priorTurn = runningStatus?.turn
const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined
if (status === 'running') clearStatus()
if (status === 'running') clearTurnStatus()
else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
else clearStatus()
else clearTurnStatus()
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined
if (status === 'running') {
@@ -1514,6 +1546,32 @@ export function createTuiChat(
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
// Track live standalone compaction state.
if (event.type === 'compact/start' && event.data.turn === null) {
if (compacting === undefined) {
const startedAt = now()
compacting = {
startedAt,
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
}
runtime.terminal.setProgress(true)
}
requestRender()
return
}
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
clearInterval(compacting.timer)
compacting = undefined
if (event.data.error !== undefined) {
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')
}
// A concurrently running turn owns the indicator. Keep its timer and
// progress bit instead of letting the compaction fade clear that state.
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
requestRender()
return
}
// A replacement mutates only the model surface, so the rendered transcript
// keeps what it already showed; a landed summary checkpoint adds its marker.
if (isReplacementSurfaceEvent(event)) {
@@ -1558,6 +1616,9 @@ export function createTuiChat(
// TUI stays mounted. Retained agents accept deliveries after detachment, so
// without this a later send would drive a zombie agent/session; mark
// disposed so dispatchMessage reports it instead.
// The hard clear also retires live compaction. A later compact/end is
// intentionally presentation-silent: this disposal notice owns the
// terminal outcome, and no animation may survive agent detachment.
clearStatus()
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
disposed = true
@@ -1580,6 +1641,7 @@ export function createTuiChat(
disposeAgent()
disposeSchemeListener()
disposeTargetListeners()
modelController.detach()
}
// Sweep reveal of the whole banner: the header wipes in left-to-right over
@@ -1645,11 +1707,11 @@ export function createTuiChat(
})
startBannerReveal()
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the
// named skill exactly as a typed `/skill:<name>` would, once the chat is live
// and the agent is idle. The launcher sets this only for a fresh session, so
// there is no prior turn to collide with; invokeSkill reports an unknown skill
// as a notice.
// A launcher-seeded first turn (`dsh migrate`/`dsh experimental-upgrade`):
// invoke the named skill exactly as a typed `/skill:<name>` would, once the
// chat is live and the agent is idle. The launcher sets this only for a fresh
// session, so there is no prior turn to collide with; invokeSkill reports an
// unknown skill as a notice.
if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '')
return {

View File

@@ -20,6 +20,7 @@ import {
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,
brand: (value: string) => `brand:${value}`,
muted: (value: string) => `muted:${value}`,
dim: (value: string) => `dim:${value}`,
accent: (value: string) => `accent:${value}`,

View File

@@ -225,6 +225,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
injectedOptions.push(input)
return input.id
},
reserveTurnAdmission: () => undefined,
cancel(cause) {
cancelled.push(cause)
},

View File

@@ -10,6 +10,7 @@ import AgentRegistry, {
import { createUserMessage,
createToolResultMessage,
freezeMessage,
LlmError,
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
@@ -39,7 +40,7 @@ import {
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import {
appendAssistant,
appendUser,
@@ -132,6 +133,12 @@ async function tick(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
function promptWidth(output: string): number {
const row = output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
async function setup(options: TuiHarnessOptions = {}) {
const terminal = new FakeTerminal()
const exit = vi.fn()
@@ -1919,12 +1926,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
// cursor never shifts. Assert both the glyph slot and that constant width
// (color is off in this harness, so output carries no ANSI to strip).
const promptWidth = (): number => {
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
// Each phase swaps only the glyph character in the same slot at equal width.
const phaseGlyph: [() => void, string][] = [
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
@@ -1937,8 +1938,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
drive()
await tick()
expect(result.terminal.output).toContain(expected)
runningWidth ??= promptWidth()
expect(promptWidth()).toBe(runningWidth)
runningWidth ??= promptWidth(result.terminal.output)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
}
// Idle begins a fade-out; once it settles (clock past the fade window) the
@@ -1955,12 +1956,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
return rows.at(-1) ?? ''
}
expect(promptRow()).toContain('dsh > ')
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
expect(promptWidth()).toBe(runningWidth)
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙⊙]/u)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
await dispose(result)
})
it('shows a live standalone compaction in the fixed status area', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
const idleWidth = promptWidth(result.terminal.output)
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.output).toContain('Context being compacted 1.0s')
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
expect(result.terminal.progress.at(-1)).toBe(true)
clock = 1_450
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('Context being compacted 1.4s')
await dispose(result)
})
it('ignores a numbered compaction bracket while the status line is idle', async () => {
const result = await setup({ now: () => 1_000 })
result.session.append('compact/start', { turn: 1 })
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('fades a closed standalone compaction back to the plain caret', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
result.session.append('compact/end', { turn: null })
await tick()
clock = 2_000
await new Promise(resolve => setTimeout(resolve, 120))
result.terminal.output = ''
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
expect(result.terminal.output).not.toContain('Context being compacted')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('reports a failed standalone compaction when its live bracket closes', async () => {
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
result.session.append('compact/start', { turn: null })
result.terminal.output = ''
result.session.append('compact/end', { turn: null, error: 'summary failed' })
await tick()
expect(result.terminal.output).toContain('Compaction failed: summary failed')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('preserves live compaction progress across an idle status edge', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
result.ctx.emit('agent/status', result.agent, 'idle')
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('keeps a running turn phase glyph ahead of standalone compaction', async () => {
let clock = 0
const result = await setup({ status: 'running', now: () => clock })
clock = 1_000
result.terminal.output = ''
result.session.append('compact/start', { turn: null })
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
result.session.append('compact/end', { turn: null })
await tick()
result.terminal.output = ''
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('treats duplicate live compaction starts as one owned bracket', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
let clock = 0
try {
result = await setup({ omitInitialLifecycle: true, now: () => clock })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
expect(intervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
result.session.append('compact/end', { turn: null })
await tick()
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
didDispose = true
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
it('does not show compaction progress for a resumed orphaned start', async () => {
const result = await setup({
omitInitialLifecycle: true,
now: () => 1_000,
beforeMount(session) {
session.append('compact/start', { turn: null })
},
})
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.output).not.toContain('Context being compacted')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('releases the live compaction timer and progress bit on dispose', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
try {
result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
expect(intervalSpy).toHaveBeenCalledOnce()
await dispose(result)
didDispose = true
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
// Extract the running glyph's interpolated gray channel from a rendered frame.
const glyphGray = (frame: string): number => {
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
@@ -2068,7 +2246,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('shows the plain prompt caret while idle', async () => {
const result = await setup({ now: () => 0 })
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
await dispose(result)
})
@@ -3558,6 +3736,96 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(reasoningFailed)
})
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
// Loader activation order is service-driven: the TUI can mount before a
// configured adapter plugin activates, so the initial resolveModelInfo
// fails with NO_ADAPTER. That transient state must not print an error;
// the resolution retries on llm/adapters-updated.
const adapters = new Set<string>()
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
contextTokens: 50_000,
catalog: {
providers: [],
models: [],
resolveModelInfo: () => adapters.has('openai-codex')
? Promise.resolve({ context: { contextWindow: 100_000 } })
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
},
})
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
// A topology commit that still lacks the route parks the wait again.
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('% context')
expect(result.terminal.output).not.toContain('Could not resolve model context')
adapters.add('openai-codex')
result.ctx.emit('llm/adapters-updated')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('% context')
})
expect(result.terminal.output).not.toContain('Could not resolve model context')
// A commit after satisfaction is a no-op for the resolved value.
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
await dispose(result)
})
it('stops listening for adapter registrations after channel detach', async () => {
// The listener disposer rides detachListeners() through the controller's
// detach(): after dispose, a registry commit must not re-enter resolution
// at all (the isDisposed() guard is a fallback, not the removal).
const calls: string[] = []
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
catalog: {
providers: [],
models: [],
resolveModelInfo: (provider) => {
calls.push(provider)
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
},
},
})
await tick()
const callsAtDetach = calls.length
await result.controller.dispose()
result.ctx.emit('llm/adapters-updated')
await tick()
expect(calls.length).toBe(callsAtDetach)
await result.ctx.fiber.dispose()
})
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelInfo: provider => provider === 'alpha'
? Promise.resolve({ context: { contextWindow: 64_000 } })
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
},
})
await tick()
// Switching the model re-resolves and clears the deferred wait, so the
// stale route's adapter arriving afterwards must be a no-op.
result.terminal.send('/model alpha/a1')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Model selected: alpha/a1')
})
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
await dispose(result)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({
@@ -3871,8 +4139,9 @@ describe('skill slash command', () => {
source: 'runtime',
content: 'Dynamic body.',
})
await tick()
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
})
result.terminal.send('\x03')
disposeSkill()
@@ -4256,6 +4525,25 @@ describe('tool cards and surface replay', () => {
diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }],
}),
},
scatteredDiff: {
name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
// Three hunks in ONE file. The first two sides end in the terminator
// newline real write/edit content carries; the third removes a line and
// leaves an EMPTY added side (a full deletion), so `diffContentLines('')`
// returns zero lines. The footer must read `+2 -1 · 1 file`: each trailing
// newline terminates its line rather than adding a phantom empty one, the
// empty side contributes no `+ ` row, and the three hunks count as the
// single distinct path they touch.
presentCall: () => ({
card: 'diff',
title: 'Edit src/scatter.ts',
diffs: [
{ path: 'src/scatter.ts', oldText: null, newText: 'first\n' },
{ path: 'src/scatter.ts', oldText: null, newText: 'second\n' },
{ path: 'src/scatter.ts', oldText: 'gone\n', newText: '' },
],
}),
},
generic: {
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
@@ -4306,6 +4594,20 @@ describe('tool cards and surface replay', () => {
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
},
// A search card carries no result text of its own; the TUI has no dedicated
// search arm and falls back to the raw result content, rendered as the same
// dim generic body a pre-search-card grep/glob result showed.
search: {
name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }),
presentResult: () => ({
card: 'search',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }],
truncated: false,
total: 1,
}),
},
symbolic: {
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
@@ -4343,6 +4645,7 @@ describe('tool cards and surface replay', () => {
['c12', 'symbolic', '{}'],
['c13', 'knownXml', '{}'],
['c16', 'webCard', '{}'],
['c17', 'search', '{"pattern":"todo"}'],
] as const
appendAssistant(result.session, [
{ type: 'text', text: 'Calling tools' },
@@ -4444,6 +4747,14 @@ describe('tool cards and surface replay', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c17' as never,
content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1,
step: 1,
@@ -4475,6 +4786,11 @@ describe('tool cards and surface replay', () => {
expect(output).toContain('$ blank desc command')
// A card whose title only repeats the name renders header-only (empty body).
expect(output).toContain('Tool / emptyBody')
// A search result view carries no `content` of its own, so the card renders
// the raw model-facing result text through the same dim generic body — the
// TUI has no dedicated search arm.
expect(output).toContain('Tool / search')
expect(output).toContain('Line 1: todo one')
// A diff card drops its title (the paths + change footer carry the meaning).
// The first file's path is head-visible; the second file and the change
// footer sit past this card's 4-line budget and appear only when expanded.
@@ -4582,6 +4898,35 @@ describe('tool cards and surface replay', () => {
await dispose(result)
})
it('counts a same-file diff once and terminates its trailing newline', async () => {
// A budget past the card's row count so every hunk row stays visible (the
// collapse arithmetic is covered elsewhere); this test is about the
// terminator rule and the distinct-path footer count.
const result = await setup({ tools, config: { maxToolOutputLines: 20 } })
appendUser(result.session, 'scatter edits in one file')
appendAssistant(result.session, [
{ type: 'text', text: 'Editing' },
{ type: 'tool-call', id: 'scatter' as never, name: 'scatteredDiff', arguments: '{}' },
])
result.session.append('tool/call', {
turn: 1, step: 1, callId: 'scatter' as never, name: 'scatteredDiff', arguments: '{}',
})
await tick()
const output = result.terminal.output
// Three hunks, one path: distinct-path count, same as the Web DiffBlock.
expect(output).toContain('· 1 file')
expect(output).not.toContain('· 3 files')
// The `first\n`/`second\n` sides each contribute exactly one added line —
// the trailing newline terminates rather than adding a phantom empty `+ `.
expect(output).toContain('+ first')
expect(output).toContain('+ second')
// The third hunk removes `gone` and leaves an empty added side, which
// contributes no `+ ` row (diffContentLines('') is zero lines).
expect(output).toContain('- gone')
expect(output).toContain('+2 -1')
await dispose(result)
})
it('drops blank rows from a terminal card result that the dim styling wraps', async () => {
const blankRowTools: Record<string, ToolDefinition> = {
trailing: {
@@ -4953,6 +5298,7 @@ describe('TUI extension service', () => {
host.theme.accent(`${label} plugin overlay`),
[
host.theme.text('text'),
host.theme.brand('brand'),
host.theme.dim('dim'),
host.theme.success('success'),
host.theme.warning('warning'),
@@ -5119,8 +5465,10 @@ describe('terminal mounting', () => {
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -5144,8 +5492,10 @@ describe('terminal mounting', () => {
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -5179,15 +5529,19 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -5217,8 +5571,10 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -5261,8 +5617,10 @@ describe('terminal mounting', () => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), status: 'running', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running', acceptsNextStep: true, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }
@@ -5330,6 +5688,10 @@ describe('terminal mounting', () => {
await dispose(result)
})
it('uses the official DeepSeek SVG ink for truecolor brand art', () => {
expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m')
})
it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => {
const result = await setup({ config: { theme: { color: true } } })
// `dim` is scheme-independent (SGR 2 over the default foreground), so the

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/user-approval/README.md
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
README.zh.md: 7f2678d8572b191ec88a326374420dde7deed3dc
README.md: 7b87a75d1c7c43874c484bc11f8deed45cb523ce
README.zh.md: c15871073231b6e97f37fc0338f4824025ba86ca

View File

@@ -8,38 +8,37 @@ Each request must belong to an open agent turn. The service appends a paired `ap
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt and policy notice
### Current approval policy context
#### What the model sees
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
##### Ask-policy prompt section
##### Ask-policy contribution
```markdown
<!-- dsh-user-approval-policy:ask -->
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
```
##### Never-policy prompt section
##### Never-policy contribution
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token effect
Small fixed per-request cost, larger under `never`; a change notice is conditional and retained in history.
One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
#### KV Cache effect
Prefix-stable while the approval policy is unchanged. An `ask`/`never` switch changes the system-prompt section and invalidates reuse from its first changed token; the accompanying notice is append-only.
Append-only after retained history. An `ask`/`never` switch preserves the stable system and conversation prefix instead of rewriting the first wire message.
### Tool outcome

View File

@@ -8,38 +8,37 @@
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其负责的会话提供一次性机器决定。
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验
### 系统提示词与策略通知
### 当前审批策略上下文
#### 模型看到的内容
在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).` 或 `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`。
首次请求和有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,批准贡献会说明可咨询已配置的应答者,缺少应答者时以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。
##### Ask 策略提示词段
##### Ask 策略贡献
```markdown
<!-- dsh-user-approval-policy:ask -->
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
```
##### Never 策略提示词段
##### Never 策略贡献
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token 影响
每个请求有少量固定成本,`never` 下的成本更高;变更通知按条件出现,并保留在历史中。
首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token。
#### KV Cache 影响
审批策略不变时,前缀保持稳定。`ask`/`never` 切换会改变系统提示词段,并从首个变化的 token 开始使复用失效;随附通知只会追加。
在保留的历史之后仅追加。`ask`/`never` 切换会保留稳定的系统与对话前缀,而不会改写第一条 wire 消息。
### 工具结果

View File

@@ -59,7 +59,7 @@ declare module '@deepseek-ai/dsh-session' {
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The LAST such
* from the cache-safe runtime-context snapshot). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy}).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
@@ -90,41 +90,17 @@ const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cance
* (exactly today's behavior).
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
* deterministically. The strict headless stance (CI, unattended runs) and
* the only policy value stated in the system prompt — unlike `'ask'`, its
* outcome is knowable without asking, so stating it cannot overclaim.
* the policy whose outcome is knowable without asking.
*/
export type ApprovalPolicy = 'ask' | 'never'
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
/**
* The prompt sentence stating a `'never'` policy — visibility for the one
* deterministic policy (see {@link ApprovalPolicy}). Narrator persistence
* does NOT parse this prose: deployments can quote it in a persona or another
* section, so the section also emits a source-owned marker.
*/
/** Model-facing statement for the deterministic `'never'` policy. */
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
/** Source-owned prompt markers used to reconstruct the policy in a logged header. */
const POLICY_MARKERS = {
ask: '<!-- dsh-user-approval-policy:ask -->',
never: '<!-- dsh-user-approval-policy:never -->',
} as const satisfies Record<ApprovalPolicy, string>
/**
* Read the policy fact emitted by this service from a logged system prompt.
* The section is ordered after deployment persona text, and the last marker
* wins so a persona quoting an earlier marker cannot shadow the service's own
* contribution. Ordinary policy prose is deliberately ignored.
*/
function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined {
if (system === undefined) return undefined
const ask = system.lastIndexOf(POLICY_MARKERS.ask)
const never = system.lastIndexOf(POLICY_MARKERS.never)
if (ask < 0 && never < 0) return undefined
return never > ask ? 'never' : 'ask'
}
/** Model-facing statement for an interactive policy that may still fail closed. */
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
/**
* The session's approval-policy override: the last `approval/policy` event in
@@ -212,7 +188,8 @@ export interface Config {
/**
* Approval service that applies session policy before answerers and logs every
* ask/outcome pair to the requesting session. It exposes deterministic policy
* changes to the model through prompt-submission notices.
* changes to the model through the cache-safe runtime-context snapshot and
* prompt-submission notices.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -224,9 +201,10 @@ export class ApprovalService extends Service {
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// State only deterministic policy; a marker records the otherwise silent state.
// The complete current value travels after retained history, so switching
// policy does not rewrite the stable system-prompt cache prefix.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
scope.systemPrompt.context({
name: 'approval:policy',
order: 115,
text: (context) => {
@@ -234,7 +212,7 @@ export class ApprovalService extends Service {
// A bare assemble() (tests, diagnostics) has no session to state.
if (agent === undefined) return ''
const policy = effective(agent)
return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
},
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
@@ -351,6 +351,7 @@ describe('ApprovalService.request', () => {
describe('approval policy (the approval/policy fold)', () => {
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
const ASK_MARKER = '<!-- dsh-user-approval-policy:ask -->'
const NEVER_MARKER = '<!-- dsh-user-approval-policy:never -->'
@@ -486,23 +487,24 @@ describe('approval policy (the approval/policy fold)', () => {
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
})
it('states never (and only never) in prose while recording either policy with a source-owned marker', async () => {
it('contributes the complete current ask or never policy as cache-safe context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const askAgent = sessionAgent('sess-sect-ask').agent
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
setApprovalPolicy(session, 'never')
const sectionFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text
expect(await sectionFor({ agent: askAgent })).toBe(ASK_MARKER)
expect(await sectionFor({ agent: neverAgent })).toBe(`${NEVER_SENTENCE}\n${NEVER_MARKER}`)
const contextFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text
expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE)
expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
// A bare assemble (no agent) has no session to state.
expect(await sectionFor({})).toBe('')
expect(await contextFor({})).toBe('')
})
it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => {
it('reflects the latest durable switch and stays byte-stable while unchanged', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-narr-1')
await submitPrompt(ctx, agent)
@@ -520,6 +522,22 @@ describe('approval policy (the approval/policy fold)', () => {
expect(narrations(session)).toHaveLength(1)
})
it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-context-switch')
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text
expect(await contextFor()).toBe(ASK_SENTENCE)
expect(await contextFor()).toBe(ASK_SENTENCE)
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
expect(await contextFor()).toBe(NEVER_SENTENCE)
expect(await contextFor()).toBe(NEVER_SENTENCE)
})
it('preserves a rejected pre-step without adding policy narration', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
@@ -637,9 +655,9 @@ describe('approval policy (the approval/policy fold)', () => {
const fiber = await ctx.plugin(ApprovalService)
const live = sessionAgent('sess-hmr-service-live')
const afterDispose = sessionAgent('sess-hmr-service-disposed')
const sectionFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).sections.find(section => section.name === 'approval:policy')
expect(await sectionFor()).toBeDefined()
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy')
expect(await contextFor()).toBeDefined()
appendHeader(live.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(live.session, 'never')
@@ -650,7 +668,7 @@ describe('approval policy (the approval/policy fold)', () => {
setApprovalPolicy(afterDispose.session, 'never')
await fiber.dispose()
expect(await sectionFor()).toBeUndefined()
expect(await contextFor()).toBeUndefined()
await submitPrompt(ctx, afterDispose.agent)
expect(narrations(afterDispose.session)).toEqual([])
})